Safari on iPhone and iPad has picture-in-picture but never implemented requestPictureInPicture; it exposes WebKit's older presentation-mode switch instead, and document.pictureInPictureEnabled is undefined there. Every capability check in the player and the feed was that one property, so they all answered "no" and the button was hidden outright on the platform where people most want it. The difference is confined to customPlayer: supportsPiP, pipElement, enterPiP and exitPiP speak for both APIs, and bindPiPEvents re-fires WebKit's webkitpresentationmodechanged -- which does not bubble -- as the standard enter/leave events, so the feed's delegated listeners, the pane pin and the media-key stepping work unchanged. Capability is read from the method's presence rather than webkitSupportsPresentationMode(), which answers false until a video track is loaded and would hide the button on preload="none" feed videos. There is no iPhone here, so smoke_ios_pip.py reshapes the browser to iOS's API surface -- deleting the standard entry points and installing WebKit's -- and drives the button through it. That covers what actually broke: which API the code reaches for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
441 lines
20 KiB
JavaScript
441 lines
20 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).
|
|
// -----------------------------------------------------------------
|
|
// iOS has picture-in-picture, but not this API: Safari on iPhone and iPad
|
|
// never implemented requestPictureInPicture, and exposes WebKit's older
|
|
// presentation-mode switch instead. document.pictureInPictureEnabled is
|
|
// undefined there, so every check below it used to answer "no" and the
|
|
// button was hidden on the one platform where people most want it.
|
|
//
|
|
// The difference is confined here. enterPiP/exitPiP/pipElement speak for
|
|
// both, and bindPiPEvents re-fires WebKit's non-bubbling
|
|
// webkitpresentationmodechanged as the standard enter/leave events, so the
|
|
// delegated listeners elsewhere work unchanged.
|
|
const WEBKIT_PIP = 'picture-in-picture';
|
|
let webkitPipElement = null;
|
|
|
|
const standardPiP = () => !!document.pictureInPictureEnabled;
|
|
const webkitPiP = (video) => !!(video && typeof video.webkitSetPresentationMode === 'function');
|
|
|
|
// No element to ask about: does this browser have either API at all?
|
|
let webkitProbe = null;
|
|
const webkitAvailable = function() {
|
|
if (webkitProbe === null) {
|
|
webkitProbe = typeof HTMLVideoElement !== 'undefined' &&
|
|
(typeof HTMLVideoElement.prototype.webkitSetPresentationMode === 'function' ||
|
|
webkitPiP(document.createElement('video')));
|
|
}
|
|
return webkitProbe;
|
|
};
|
|
|
|
// Deliberately the method's presence rather than
|
|
// video.webkitSupportsPresentationMode(): that answers false until a video
|
|
// track is loaded, and feed videos are preload="none" until they go active
|
|
// -- it would hide the button on exactly the videos about to be able to
|
|
// use it.
|
|
App.customPlayer.supportsPiP = function() {
|
|
return standardPiP() || webkitAvailable();
|
|
};
|
|
|
|
App.customPlayer.pipElement = function() {
|
|
return document.pictureInPictureElement || webkitPipElement || null;
|
|
};
|
|
|
|
App.customPlayer.enterPiP = async function(video) {
|
|
if (!video) return false;
|
|
if (standardPiP()) {
|
|
if (video.disablePictureInPicture) return false;
|
|
try {
|
|
await video.requestPictureInPicture();
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
}
|
|
if (!webkitPiP(video)) return false;
|
|
try {
|
|
// Synchronous, and it needs the user gesture that got us here.
|
|
video.webkitSetPresentationMode(WEBKIT_PIP);
|
|
return true;
|
|
} catch (err) {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
App.customPlayer.exitPiP = async function() {
|
|
if (document.pictureInPictureElement) {
|
|
await document.exitPictureInPicture().catch(() => {});
|
|
return;
|
|
}
|
|
const video = webkitPipElement;
|
|
if (!webkitPiP(video)) return;
|
|
try { video.webkitSetPresentationMode('inline'); } catch (err) { /* already gone */ }
|
|
};
|
|
|
|
App.customPlayer.bindPiPEvents = function(video) {
|
|
if (standardPiP() || !webkitPiP(video)) return function destroy() {};
|
|
// The same event announces fullscreen and inline, so only a real change
|
|
// in picture-in-picture-ness is worth reporting.
|
|
let wasPip = video.webkitPresentationMode === WEBKIT_PIP;
|
|
const onChange = function() {
|
|
const isPip = video.webkitPresentationMode === WEBKIT_PIP;
|
|
if (isPip === wasPip) return;
|
|
wasPip = isPip;
|
|
if (isPip) webkitPipElement = video;
|
|
else if (webkitPipElement === video) webkitPipElement = null;
|
|
video.dispatchEvent(new CustomEvent(
|
|
isPip ? 'enterpictureinpicture' : 'leavepictureinpicture', { bubbles: true }));
|
|
};
|
|
video.addEventListener('webkitpresentationmodechanged', onChange);
|
|
return function destroy() {
|
|
video.removeEventListener('webkitpresentationmodechanged', onChange);
|
|
if (webkitPipElement === video) webkitPipElement = null;
|
|
};
|
|
};
|
|
|
|
App.customPlayer.togglePiP = async function(video) {
|
|
if (!video) return false;
|
|
if (App.customPlayer.pipElement() === video) {
|
|
await App.customPlayer.exitPiP();
|
|
return true;
|
|
}
|
|
return App.customPlayer.enterPiP(video);
|
|
};
|
|
|
|
// 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 unbindEvents = App.customPlayer.bindPiPEvents(video);
|
|
const trigger = () => {
|
|
if (document.visibilityState !== 'hidden') return;
|
|
if (!App.customPlayer.supportsPiP() || video.disablePictureInPicture) return;
|
|
if (App.customPlayer.pipElement()) return;
|
|
if (video.paused || video.ended) return;
|
|
App.customPlayer.enterPiP(video);
|
|
};
|
|
document.addEventListener('visibilitychange', trigger);
|
|
window.addEventListener('pagehide', trigger);
|
|
return function destroy() {
|
|
App.customPlayer.setAutoPiP(video, false);
|
|
unbindEvents();
|
|
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);
|
|
};
|
|
};
|
|
})();
|