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>
303 lines
13 KiB
JavaScript
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);
|
|
};
|
|
};
|
|
})();
|