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>
This commit is contained in:
302
frontend/js/customPlayer.js
Normal file
302
frontend/js/customPlayer.js
Normal file
@@ -0,0 +1,302 @@
|
||||
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);
|
||||
};
|
||||
};
|
||||
})();
|
||||
Reference in New Issue
Block a user