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