Two things, both about playing several videos at once.
Capping the resolution per panel wasn't enough, because pixel count isn't
the only cost. A split panel now also prefers a progressive file over HLS
-- every HLS panel runs its own JavaScript demuxer over every segment, so
four panels means four media pipelines doing work a plain MP4 skips
entirely -- and H.264 over AV1 or VP9, which are often decoded in software
and are a cliff rather than a gradient, and 30fps over 60. The height
ceiling still comes first, so cheapness cannot argue a panel into a bigger
picture than it should have, and every format stays reachable as fallback.
The preloaded step's hls.js instances now park after buffering one
fragment and resume when the reader swipes to them, instead of fetching
and demuxing ahead for a step nobody reached.
Auto picture-in-picture had been implemented since the custom player was
written and had never worked. requestPictureInPicture() from a
visibilitychange handler carries no user activation, browsers refuse those,
and .catch(() => {}) swallowed the refusal -- so it failed silently every
time, in the reels feed and the standalone player alike. The declarative
autoPictureInPicture attribute is the form made for this: the browser is
told in advance which video should follow the reader out. The imperative
call stays as a fallback.
With panels there are several candidates and only one window, so binding
every pane made them race for it. The feed picks one deliberately -- the
panel you can hear, or the first if they are all muted -- re-picks when the
step or a mute switch changes, and releases it on close.
Whether a window actually opens is browser policy, not ours: Safari honours
the attribute, Chrome honours it for installed apps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
356 lines
16 KiB
JavaScript
356 lines
16 KiB
JavaScript
window.App = window.App || {};
|
|
App.customPlayer = App.customPlayer || {};
|
|
|
|
// Shared building blocks for the custom video player HUD, reused by the
|
|
// standalone fullscreen player (player.js) and the reels feed (feed.js) so
|
|
// both present identical skip/format/gesture/PiP behavior.
|
|
(function() {
|
|
// -----------------------------------------------------------------
|
|
// Skip escalation: tapping skip-forward/back repeatedly ramps the skip
|
|
// duration up (5 -> 10 -> 20 -> 40 -> 60s), independently per direction,
|
|
// so mashing forward doesn't also ramp up backward. A tap lands as
|
|
// "rapid" (and escalates further) only if it arrives within
|
|
// RAPID_WINDOW_MS of the previous same-direction tap. After GRACE_MS of
|
|
// silence the level steps back down by one every DECAY_STEP_MS.
|
|
// -----------------------------------------------------------------
|
|
const LEVELS = [5, 10, 20, 40, 60];
|
|
const RAPID_WINDOW_MS = 1500;
|
|
const GRACE_MS = 2000;
|
|
const DECAY_STEP_MS = 1000;
|
|
|
|
App.customPlayer.createSkipEscalator = function() {
|
|
const dirs = {
|
|
back: { levelIndex: 0, lastTapAt: 0, decayTimer: null },
|
|
forward: { levelIndex: 0, lastTapAt: 0, decayTimer: null }
|
|
};
|
|
|
|
const clearDecay = (d) => {
|
|
if (d.decayTimer) {
|
|
clearTimeout(d.decayTimer);
|
|
d.decayTimer = null;
|
|
}
|
|
};
|
|
|
|
const scheduleDecay = (d) => {
|
|
clearDecay(d);
|
|
d.decayTimer = setTimeout(function tick() {
|
|
d.decayTimer = null;
|
|
if (d.levelIndex > 0) {
|
|
d.levelIndex -= 1;
|
|
d.decayTimer = setTimeout(tick, DECAY_STEP_MS);
|
|
}
|
|
}, GRACE_MS);
|
|
};
|
|
|
|
// Advances the state for a tap in `direction` and returns the number
|
|
// of seconds that tap should skip.
|
|
const trigger = function(direction) {
|
|
const d = dirs[direction];
|
|
if (!d) return LEVELS[0];
|
|
const now = Date.now();
|
|
if (now - d.lastTapAt <= RAPID_WINDOW_MS && d.levelIndex < LEVELS.length - 1) {
|
|
d.levelIndex += 1;
|
|
}
|
|
d.lastTapAt = now;
|
|
scheduleDecay(d);
|
|
return LEVELS[d.levelIndex];
|
|
};
|
|
|
|
const destroy = function() {
|
|
clearDecay(dirs.back);
|
|
clearDecay(dirs.forward);
|
|
};
|
|
|
|
return { trigger, destroy };
|
|
};
|
|
|
|
// Applies one skip tap to `video` using `escalator`, clamped to the
|
|
// media's bounds. Returns the number of seconds skipped (for HUD flash
|
|
// feedback), or 0 if the video has no usable duration yet.
|
|
App.customPlayer.skip = function(video, direction, escalator) {
|
|
if (!video) return 0;
|
|
const amount = escalator.trigger(direction);
|
|
const delta = direction === 'forward' ? amount : -amount;
|
|
let target = video.currentTime + delta;
|
|
if (isFinite(video.duration) && video.duration > 0) {
|
|
target = Math.min(target, Math.max(0, video.duration - 0.1));
|
|
}
|
|
video.currentTime = Math.max(0, target);
|
|
return amount;
|
|
};
|
|
|
|
// -----------------------------------------------------------------
|
|
// Format switching: builds a labeled, ranked list of a video's playable
|
|
// formats (quality/codec/container variants) for a picker menu. Reuses
|
|
// App.videos.rankFormats (same ranking as automatic selection) with no
|
|
// preferred-height ceiling, since a manual pick overrides that entirely.
|
|
// -----------------------------------------------------------------
|
|
App.customPlayer.formatLabel = function(fmt) {
|
|
if (!fmt) return 'Auto';
|
|
const parts = [];
|
|
const height = App.videos.coerceNumber(fmt.height);
|
|
const fps = App.videos.coerceNumber(fmt.fps);
|
|
if (height) parts.push(`${height}p${fps > 30 ? Math.round(fps) : ''}`);
|
|
// The container (mp4/webm) tells the viewer nothing useful about a
|
|
// quality choice. The extractor's own note does -- but only when it
|
|
// says something the quality doesn't already ("HDR", "source", a
|
|
// codec), so drop one that merely restates it ("1080p", "1080p60").
|
|
const note = (fmt.format_note || '').toString().trim();
|
|
if (note && note.toLowerCase() !== (parts[0] || '').toLowerCase()) parts.push(note);
|
|
if (!parts.length) {
|
|
const vcodec = (fmt.vcodec || '').toString();
|
|
parts.push(vcodec && vcodec !== 'none' ? vcodec : 'Auto');
|
|
}
|
|
return parts.join(' ');
|
|
};
|
|
|
|
// Returns [] when there's nothing to pick from (no formats, or only one
|
|
// usable variant) so callers know to hide the format-switch button.
|
|
App.customPlayer.buildFormatOptions = function(video) {
|
|
const meta = video && (video.meta || video);
|
|
if (!meta || !Array.isArray(meta.formats) || meta.formats.length < 2) return [];
|
|
const ranked = App.videos.rankFormats(meta.formats, null);
|
|
if (ranked.length < 2) return [];
|
|
return ranked.map((fmt) => ({ fmt, label: App.customPlayer.formatLabel(fmt) }));
|
|
};
|
|
|
|
// Wires a format-switch button + its dropdown menu against `videoData`,
|
|
// calling onSelect(fmt) when the user picks one. Hides the button when
|
|
// there's nothing to pick from. Shared by the standalone player and the
|
|
// reels feed so both present an identical menu. Returns a destroy() fn.
|
|
// `options.getCurrentUrl` (optional) returns the URL the player is actually
|
|
// feeding to the media element right now; the matching entry is marked
|
|
// active every time the menu opens. Reading it live rather than at bind
|
|
// time keeps the mark honest when playback moved on by itself -- an
|
|
// automatic pick, a fallback to the next candidate after a failure, or a
|
|
// re-resolve -- not just when the viewer chose from this menu.
|
|
App.customPlayer.bindFormatMenu = function(btn, menu, videoData, onSelect, opts) {
|
|
if (!btn || !menu) return function destroy() {};
|
|
const getCurrentUrl = (opts && opts.getCurrentUrl) || null;
|
|
const options = App.customPlayer.buildFormatOptions(videoData);
|
|
if (!options.length) {
|
|
btn.hidden = true;
|
|
menu.hidden = true;
|
|
menu.innerHTML = '';
|
|
return function destroy() {};
|
|
}
|
|
btn.hidden = false;
|
|
menu.hidden = true;
|
|
menu.innerHTML = options.map((opt, i) =>
|
|
`<button class="cp-format-option" type="button" role="menuitemradio" aria-checked="false" data-index="${i}">${opt.label}</button>`
|
|
).join('');
|
|
const markActive = (activeBtn) => {
|
|
menu.querySelectorAll('.cp-format-option').forEach((b) => {
|
|
const isActive = b === activeBtn;
|
|
b.classList.toggle('is-active', isActive);
|
|
b.setAttribute('aria-checked', isActive ? 'true' : 'false');
|
|
});
|
|
};
|
|
const syncActive = () => {
|
|
const current = getCurrentUrl ? (getCurrentUrl() || '') : '';
|
|
let match = null;
|
|
if (current) {
|
|
options.forEach((opt, i) => {
|
|
if (!match && opt.fmt && opt.fmt.url === current) {
|
|
match = menu.querySelector(`.cp-format-option[data-index="${i}"]`);
|
|
}
|
|
});
|
|
}
|
|
markActive(match);
|
|
};
|
|
const cleanups = [];
|
|
menu.querySelectorAll('.cp-format-option').forEach((optBtn) => {
|
|
const onClick = (event) => {
|
|
event.stopPropagation();
|
|
const idx = parseInt(optBtn.dataset.index, 10);
|
|
const opt = options[idx];
|
|
menu.hidden = true;
|
|
markActive(optBtn);
|
|
if (opt) onSelect(opt.fmt);
|
|
};
|
|
optBtn.addEventListener('click', onClick);
|
|
cleanups.push(() => optBtn.removeEventListener('click', onClick));
|
|
});
|
|
const onBtnClick = (event) => {
|
|
event.stopPropagation();
|
|
if (menu.hidden) {
|
|
syncActive();
|
|
// Opening the menu restarts the HUD's idle countdown: the menu
|
|
// hides with the HUD, and the viewer needs the full window to
|
|
// read the list, not whatever was left of the previous one.
|
|
if (opts && opts.onOpen) opts.onOpen();
|
|
}
|
|
menu.hidden = !menu.hidden;
|
|
};
|
|
btn.addEventListener('click', onBtnClick);
|
|
cleanups.push(() => btn.removeEventListener('click', onBtnClick));
|
|
return function destroy() {
|
|
cleanups.forEach((fn) => fn());
|
|
};
|
|
};
|
|
|
|
// -----------------------------------------------------------------
|
|
// Picture-in-Picture: a manual toggle plus best-effort auto-PiP when the
|
|
// tab/app is backgrounded while a video is playing (Safari does this
|
|
// natively for inline video; Chrome/Android need an explicit call).
|
|
// -----------------------------------------------------------------
|
|
App.customPlayer.supportsPiP = function() {
|
|
return !!(document.pictureInPictureEnabled);
|
|
};
|
|
|
|
App.customPlayer.togglePiP = async function(video) {
|
|
if (!video || !document.pictureInPictureEnabled || video.disablePictureInPicture) return false;
|
|
try {
|
|
if (document.pictureInPictureElement === video) {
|
|
await document.exitPictureInPicture();
|
|
} else {
|
|
await video.requestPictureInPicture();
|
|
}
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Asking for picture-in-picture the moment a tab is hidden is a request
|
|
// with no user gesture behind it, and browsers refuse those -- which is why
|
|
// the imperative call below fails silently. `autoPictureInPicture` is the
|
|
// declarative form made for exactly this: the browser is told in advance
|
|
// which video should follow the reader out, and does it itself. Safari
|
|
// honours it outright; Chrome honours it for installed apps. The call is
|
|
// kept as a fallback for anywhere the flag is ignored but the request is
|
|
// allowed.
|
|
App.customPlayer.setAutoPiP = function(video, on) {
|
|
if (!video) return;
|
|
try { video.autoPictureInPicture = !!on; } catch (err) { /* unsupported */ }
|
|
if (on) video.setAttribute('autopictureinpicture', '');
|
|
else video.removeAttribute('autopictureinpicture');
|
|
};
|
|
|
|
App.customPlayer.bindAutoPiP = function(video) {
|
|
if (!video) return function destroy() {};
|
|
App.customPlayer.setAutoPiP(video, true);
|
|
const trigger = () => {
|
|
if (document.visibilityState !== 'hidden') return;
|
|
if (!document.pictureInPictureEnabled || video.disablePictureInPicture) return;
|
|
if (document.pictureInPictureElement) return;
|
|
if (video.paused || video.ended) return;
|
|
video.requestPictureInPicture().catch(() => {});
|
|
};
|
|
document.addEventListener('visibilitychange', trigger);
|
|
window.addEventListener('pagehide', trigger);
|
|
return function destroy() {
|
|
App.customPlayer.setAutoPiP(video, false);
|
|
document.removeEventListener('visibilitychange', trigger);
|
|
window.removeEventListener('pagehide', trigger);
|
|
};
|
|
};
|
|
|
|
// -----------------------------------------------------------------
|
|
// Unified pointer-gesture recognizer for the video surface: a single
|
|
// pointer stream is classified into exactly one of tap / double-tap /
|
|
// volume-drag (right column) / dismiss-drag (top strip), so the gestures
|
|
// never fight each other over the same touch.
|
|
// -----------------------------------------------------------------
|
|
App.customPlayer.attachGestures = function(surfaceEl, handlers) {
|
|
handlers = handlers || {};
|
|
const TAP_MAX_MOVE = 10;
|
|
const DOUBLE_TAP_MS = 300;
|
|
const DISMISS_ZONE_FRACTION = 0.2; // top strip that owns swipe-to-dismiss
|
|
const VOLUME_ZONE_START = 0.66; // right column that owns volume swipe
|
|
const SKIP_ZONE_LEFT_END = 0.34;
|
|
const SKIP_ZONE_RIGHT_START = 0.66;
|
|
|
|
let pointerId = null;
|
|
let startX = 0, startY = 0, lastY = 0;
|
|
let moved = false;
|
|
let mode = null; // 'dismiss-candidate' | 'dismiss' | 'volume-candidate' | 'volume' | 'ignore'
|
|
let volumeStartValue = 0;
|
|
let lastTapTime = 0;
|
|
let lastTapSide = null;
|
|
|
|
const rectOf = () => surfaceEl.getBoundingClientRect();
|
|
|
|
const ignoreSelector = handlers.ignoreSelector || 'button, input, a, .cp-format-menu';
|
|
|
|
const onPointerDown = (e) => {
|
|
if (pointerId != null || e.button != null && e.button !== 0) return;
|
|
if (e.target && e.target.closest && e.target.closest(ignoreSelector)) return;
|
|
pointerId = e.pointerId;
|
|
startX = e.clientX;
|
|
startY = lastY = e.clientY;
|
|
moved = false;
|
|
mode = null;
|
|
const rect = rectOf();
|
|
const relX = rect.width ? (e.clientX - rect.left) / rect.width : 0;
|
|
const relY = rect.height ? (e.clientY - rect.top) / rect.height : 0;
|
|
if (relY <= DISMISS_ZONE_FRACTION && handlers.onDismissDrag) {
|
|
mode = 'dismiss-candidate';
|
|
} else if (relX >= VOLUME_ZONE_START && handlers.onVolumeDrag) {
|
|
mode = 'volume-candidate';
|
|
volumeStartValue = handlers.onVolumeStart ? handlers.onVolumeStart() : 0;
|
|
}
|
|
try { surfaceEl.setPointerCapture(e.pointerId); } catch (err) { /* ignore */ }
|
|
};
|
|
|
|
const onPointerMove = (e) => {
|
|
if (e.pointerId !== pointerId) return;
|
|
const dx = e.clientX - startX;
|
|
const dy = e.clientY - startY;
|
|
if (!moved && Math.hypot(dx, dy) > TAP_MAX_MOVE) moved = true;
|
|
if (moved) {
|
|
if (mode === 'dismiss-candidate') mode = 'dismiss';
|
|
else if (mode === 'volume-candidate') mode = 'volume';
|
|
else if (mode === null) mode = 'ignore';
|
|
|
|
if (mode === 'dismiss') {
|
|
handlers.onDismissDrag(dy, rectOf());
|
|
} else if (mode === 'volume') {
|
|
const rect = rectOf();
|
|
const deltaRatio = rect.height ? (startY - e.clientY) / rect.height : 0;
|
|
handlers.onVolumeDrag(Math.min(1, Math.max(0, volumeStartValue + deltaRatio)));
|
|
}
|
|
}
|
|
lastY = e.clientY;
|
|
};
|
|
|
|
const endGesture = (e) => {
|
|
if (e.pointerId !== pointerId) return;
|
|
try { surfaceEl.releasePointerCapture(e.pointerId); } catch (err) { /* ignore */ }
|
|
if (moved) {
|
|
if (mode === 'dismiss' && handlers.onDismissEnd) handlers.onDismissEnd(lastY - startY);
|
|
else if (mode === 'volume' && handlers.onVolumeEnd) handlers.onVolumeEnd();
|
|
} else {
|
|
if (handlers.onSingleTap) handlers.onSingleTap();
|
|
const rect = rectOf();
|
|
const relX = rect.width ? (startX - rect.left) / rect.width : 0.5;
|
|
const side = relX <= SKIP_ZONE_LEFT_END ? 'left' : (relX >= SKIP_ZONE_RIGHT_START ? 'right' : 'center');
|
|
const now = Date.now();
|
|
if (side !== 'center' && lastTapSide === side && (now - lastTapTime) <= DOUBLE_TAP_MS) {
|
|
lastTapTime = 0;
|
|
lastTapSide = null;
|
|
if (side === 'left' && handlers.onDoubleTapLeft) handlers.onDoubleTapLeft();
|
|
if (side === 'right' && handlers.onDoubleTapRight) handlers.onDoubleTapRight();
|
|
} else {
|
|
lastTapTime = now;
|
|
lastTapSide = side;
|
|
}
|
|
}
|
|
pointerId = null;
|
|
mode = null;
|
|
};
|
|
|
|
surfaceEl.addEventListener('pointerdown', onPointerDown);
|
|
surfaceEl.addEventListener('pointermove', onPointerMove);
|
|
surfaceEl.addEventListener('pointerup', endGesture);
|
|
surfaceEl.addEventListener('pointercancel', endGesture);
|
|
|
|
return function destroy() {
|
|
surfaceEl.removeEventListener('pointerdown', onPointerDown);
|
|
surfaceEl.removeEventListener('pointermove', onPointerMove);
|
|
surfaceEl.removeEventListener('pointerup', endGesture);
|
|
surfaceEl.removeEventListener('pointercancel', endGesture);
|
|
};
|
|
};
|
|
})();
|