Files
jacuzzi/frontend/js/customPlayer.js
Simon 7207e36510 Replace video player with a fully custom fake-fullscreen HUD
Single fullscreen player state everywhere (desktop/Android/iOS/feed) instead
of the old modal-vs-native-fullscreen split, with custom controls: draggable
timeline with buffered range, dynamically-escalating skip buttons (double-tap
zones too), per-video format switching, favorites, PiP with auto-PiP on
backgrounding, volume swipe, TikTok-style HUD auto-hide, and swipe-down/
back-button/close-button dismissal. Reels feed reuses the same skip/format/
PiP logic via the new customPlayer.js shared module.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-01 19:08:21 +00:00

303 lines
13 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) : ''}`);
const ext = (fmt.ext || fmt.video_ext || '').toString();
if (ext) parts.push(ext);
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.
App.customPlayer.bindFormatMenu = function(btn, menu, videoData, onSelect) {
if (!btn || !menu) return function destroy() {};
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" data-index="${i}">${opt.label}</button>`
).join('');
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;
menu.querySelectorAll('.cp-format-option').forEach((b) => b.classList.remove('is-active'));
optBtn.classList.add('is-active');
if (opt) onSelect(opt.fmt);
};
optBtn.addEventListener('click', onClick);
cleanups.push(() => optBtn.removeEventListener('click', onClick));
});
const onBtnClick = (event) => {
event.stopPropagation();
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;
}
};
App.customPlayer.bindAutoPiP = function(video) {
if (!video) return function destroy() {};
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() {
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);
};
};
})();