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) => `` ).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); }; }; })();