window.App = window.App || {}; App.player = App.player || {}; // Fully custom fullscreen video player. There is a single player state (no // separate "windowed" mode): opening any video fills the viewport with a // fixed-position overlay ("fake fullscreen" — a CSS overlay rather than the // real Fullscreen API, so the same HUD/gesture code works identically on // desktop, Android and iOS, where native fullscreen video can't host custom // HTML controls). (function() { const state = App.state; const HUD_IDLE_MS = 2500; const DISMISS_THRESHOLD_PX = 90; // Module-local player state, separate from App.state (which other // modules read/write for unrelated things). const cp = { container: null, video: null, formatOverride: null, // manually chosen fmt object, or null (auto) cleanups: [], historyPushed: false, idleTimer: null, originEl: null, attemptToken: 0 // bumps on every open()/format switch to void stale async callbacks }; const addCleanup = (fn) => cp.cleanups.push(fn); const runCleanups = () => { cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } }); cp.cleanups = []; }; const q = (selector) => cp.container ? cp.container.querySelector(selector) : null; // --------------------------------------------------------------------- // DOM construction // --------------------------------------------------------------------- function buildContainer() { let container = document.getElementById('custom-player'); if (!container) { container = document.createElement('div'); container.id = 'custom-player'; document.body.appendChild(container); } container.className = 'custom-player'; container.innerHTML = `

0:00
0:00
`; return container; } // --------------------------------------------------------------------- // Title marquee (mirrors App.videos' card marquee math, scoped locally // since the player title isn't a `.video-card`). // --------------------------------------------------------------------- function measureTitle() { const wrap = q('.cp-title'); const text = q('.cp-title-text'); if (!wrap || !text) return; const overflow = text.scrollWidth - wrap.clientWidth; if (overflow > 4) { const distance = overflow + 12; const MARQUEE_SPEED = 28; const MARQUEE_MIN_DURATION = 6; const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED); text.style.setProperty('--marquee-distance', `${distance}px`); text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`); wrap.classList.add('has-marquee'); } else { wrap.classList.remove('has-marquee'); text.style.removeProperty('--marquee-distance'); } } // --------------------------------------------------------------------- // HUD auto-hide (TikTok-style fade on inactivity) // --------------------------------------------------------------------- function clearIdleTimer() { if (cp.idleTimer) { clearTimeout(cp.idleTimer); cp.idleTimer = null; } } function scheduleHudHide() { clearIdleTimer(); cp.idleTimer = setTimeout(() => { cp.idleTimer = null; if (cp.container) cp.container.classList.add('cp-hud-idle'); }, HUD_IDLE_MS); } function wakeHud() { if (!cp.container) return; cp.container.classList.remove('cp-hud-idle'); scheduleHudHide(); } // --------------------------------------------------------------------- // Flash feedback (skip amount, etc.) // --------------------------------------------------------------------- function flash(text) { const flashEl = q('.cp-flash'); if (!flashEl) return; flashEl.textContent = text; flashEl.classList.remove('is-visible'); void flashEl.offsetWidth; // restart the fade animation flashEl.classList.add('is-visible'); } // --------------------------------------------------------------------- // Timeline (draggable seek + buffered range) // --------------------------------------------------------------------- function updateBuffered(video) { const bufferedEl = q('.cp-timeline-buffered'); if (!bufferedEl) return; if (!video.buffered || !video.buffered.length || !isFinite(video.duration) || video.duration <= 0) { bufferedEl.style.width = '0%'; return; } const end = video.buffered.end(video.buffered.length - 1); bufferedEl.style.width = `${Math.min(100, (end / video.duration) * 100)}%`; } function setTimelinePosition(ratio) { const fill = q('.cp-timeline-fill'); const handle = q('.cp-timeline-handle'); const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`; if (fill) fill.style.width = pct; if (handle) handle.style.left = pct; } function bindTimeline(video) { const timeline = q('.cp-timeline'); const currentEl = q('.cp-time-current'); const durationEl = q('.cp-time-duration'); if (!timeline) return; let scrubbing = false; const onTimeUpdate = () => { if (!scrubbing && isFinite(video.duration) && video.duration > 0) { setTimelinePosition(video.currentTime / video.duration); } if (currentEl) currentEl.textContent = App.videos.formatDuration(video.currentTime) || '0:00'; updateBuffered(video); }; const onLoadedMeta = () => { if (durationEl) durationEl.textContent = App.videos.formatDuration(video.duration) || '0:00'; }; const onProgress = () => updateBuffered(video); video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('loadedmetadata', onLoadedMeta); video.addEventListener('progress', onProgress); addCleanup(() => { video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('loadedmetadata', onLoadedMeta); video.removeEventListener('progress', onProgress); }); const seekFromPointer = (clientX) => { if (!isFinite(video.duration) || video.duration <= 0) return; const rect = timeline.getBoundingClientRect(); const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0; const clamped = Math.min(1, Math.max(0, ratio)); video.currentTime = clamped * video.duration; setTimelinePosition(clamped); }; const onDown = (event) => { scrubbing = true; timeline.classList.add('is-scrubbing'); timeline.setPointerCapture(event.pointerId); seekFromPointer(event.clientX); event.preventDefault(); event.stopPropagation(); wakeHud(); }; const onMove = (event) => { if (!scrubbing) return; seekFromPointer(event.clientX); event.preventDefault(); event.stopPropagation(); }; const onUpEvt = (event) => { if (!scrubbing) return; scrubbing = false; timeline.classList.remove('is-scrubbing'); if (timeline.hasPointerCapture(event.pointerId)) timeline.releasePointerCapture(event.pointerId); event.stopPropagation(); }; timeline.addEventListener('pointerdown', onDown); timeline.addEventListener('pointermove', onMove); timeline.addEventListener('pointerup', onUpEvt); timeline.addEventListener('pointercancel', onUpEvt); addCleanup(() => { timeline.removeEventListener('pointerdown', onDown); timeline.removeEventListener('pointermove', onMove); timeline.removeEventListener('pointerup', onUpEvt); timeline.removeEventListener('pointercancel', onUpEvt); }); } // --------------------------------------------------------------------- // Favorite button (shared state with the grid/feed heart toggle) // --------------------------------------------------------------------- function bindFavorite(videoData) { const btn = q('.cp-fav-btn'); if (!btn || !App.favorites) return; const key = App.favorites.getKey(videoData); if (!key) { btn.hidden = true; return; } btn.dataset.favKey = key; App.favorites.setButtonState(btn, App.favorites.getSet().has(key)); const onClick = (event) => { event.stopPropagation(); App.favorites.toggle(videoData); }; btn.addEventListener('click', onClick); addCleanup(() => btn.removeEventListener('click', onClick)); } // --------------------------------------------------------------------- // Transport: play/pause, skip w/ escalation, mute/volume, PiP // --------------------------------------------------------------------- function bindTransport(video) { const playBtn = q('.cp-play-btn'); const playIcon = q('.cp-play-icon'); const skipBackBtn = q('.cp-skip-back-btn'); const skipFwdBtn = q('.cp-skip-fwd-btn'); const muteBtn = q('.cp-mute-btn'); const muteIcon = q('.cp-mute-icon'); const volumeRange = q('.cp-volume-range'); const pipBtn = q('.cp-pip-btn'); const replayBtn = q('.cp-replay-btn'); const updatePlayIcon = () => { if (playIcon) { playIcon.src = video.paused ? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/play.svg' : 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/pause.svg'; } if (playBtn) playBtn.setAttribute('aria-label', video.paused ? 'Play' : 'Pause'); }; const onPlay = () => { updatePlayIcon(); if (replayBtn) replayBtn.hidden = true; }; const onPause = updatePlayIcon; video.addEventListener('play', onPlay); video.addEventListener('pause', onPause); addCleanup(() => { video.removeEventListener('play', onPlay); video.removeEventListener('pause', onPause); }); if (playBtn) { const onPlayClick = (event) => { event.stopPropagation(); if (video.paused) { const p = video.play(); if (p && p.catch) p.catch(() => {}); } else { video.pause(); } wakeHud(); }; playBtn.addEventListener('click', onPlayClick); addCleanup(() => playBtn.removeEventListener('click', onPlayClick)); } const escalator = App.customPlayer.createSkipEscalator(); addCleanup(() => escalator.destroy()); const doSkip = (direction) => { const amount = App.customPlayer.skip(video, direction, escalator); const back = skipBackBtn && skipBackBtn.querySelector('.cp-skip-amount'); const fwd = skipFwdBtn && skipFwdBtn.querySelector('.cp-skip-amount'); if (direction === 'back' && back) back.textContent = String(amount); if (direction === 'forward' && fwd) fwd.textContent = String(amount); flash(`${direction === 'forward' ? '+' : '-'}${amount}s`); wakeHud(); }; App.player._doSkip = doSkip; // exposed for gesture wiring below if (skipBackBtn) { const onClick = (event) => { event.stopPropagation(); doSkip('back'); }; skipBackBtn.addEventListener('click', onClick); addCleanup(() => skipBackBtn.removeEventListener('click', onClick)); } if (skipFwdBtn) { const onClick = (event) => { event.stopPropagation(); doSkip('forward'); }; skipFwdBtn.addEventListener('click', onClick); addCleanup(() => skipFwdBtn.removeEventListener('click', onClick)); } const updateMuteIcon = () => { if (muteIcon) { muteIcon.src = (video.muted || video.volume === 0) ? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg' : 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg'; } if (volumeRange) volumeRange.value = video.muted ? 0 : video.volume; }; updateMuteIcon(); const onVolumeChange = updateMuteIcon; video.addEventListener('volumechange', onVolumeChange); addCleanup(() => video.removeEventListener('volumechange', onVolumeChange)); if (muteBtn) { const onClick = (event) => { event.stopPropagation(); video.muted = !video.muted; if (!video.muted && video.volume === 0) video.volume = 1; wakeHud(); }; muteBtn.addEventListener('click', onClick); addCleanup(() => muteBtn.removeEventListener('click', onClick)); } if (volumeRange) { const onInput = () => { video.volume = parseFloat(volumeRange.value); video.muted = video.volume === 0; wakeHud(); }; volumeRange.addEventListener('input', onInput); addCleanup(() => volumeRange.removeEventListener('input', onInput)); } if (pipBtn) { pipBtn.hidden = !App.customPlayer.supportsPiP(); const onClick = async (event) => { event.stopPropagation(); await App.customPlayer.togglePiP(video); }; pipBtn.addEventListener('click', onClick); addCleanup(() => pipBtn.removeEventListener('click', onClick)); } addCleanup(App.customPlayer.bindAutoPiP(video)); if (replayBtn) { const onClick = (event) => { event.stopPropagation(); replayBtn.hidden = true; video.currentTime = 0; const p = video.play(); if (p && p.catch) p.catch(() => {}); }; replayBtn.addEventListener('click', onClick); addCleanup(() => replayBtn.removeEventListener('click', onClick)); } const onEnded = () => { clearIdleTimer(); if (cp.container) cp.container.classList.remove('cp-hud-idle'); if (replayBtn) replayBtn.hidden = false; }; video.addEventListener('ended', onEnded); addCleanup(() => video.removeEventListener('ended', onEnded)); } // --------------------------------------------------------------------- // Gestures: tap wakes the HUD, double-tap left/right skips, right-column // vertical swipe adjusts volume, top-strip swipe-down dismisses. // --------------------------------------------------------------------- function bindGestures(video) { const surface = q('.cp-surface'); if (!surface) return; const destroy = App.customPlayer.attachGestures(surface, { // Always wakes (never explicitly hides) so a double-tap-to-skip // doesn't flicker the HUD off-then-on between its two taps; // hiding is left entirely to the idle-fade timer. onSingleTap: () => wakeHud(), onDoubleTapLeft: () => App.player._doSkip('back'), onDoubleTapRight: () => App.player._doSkip('forward'), onVolumeStart: () => (video.muted ? 0 : video.volume), onVolumeDrag: (value) => { video.volume = value; video.muted = value === 0; flash(`${Math.round(value * 100)}%`); }, onVolumeEnd: () => wakeHud(), onDismissDrag: (dy) => { const clamped = Math.max(0, dy); cp.container.style.transform = `translateY(${clamped}px)`; cp.container.style.opacity = String(Math.max(0.4, 1 - clamped / 400)); }, onDismissEnd: (dy) => { cp.container.style.transform = ''; cp.container.style.opacity = ''; if (dy > DISMISS_THRESHOLD_PX) { App.player.close(); } } }); addCleanup(destroy); } // --------------------------------------------------------------------- // Keyboard shortcuts (desktop) // --------------------------------------------------------------------- function bindKeyboard(video) { const onKeyDown = (event) => { if (!cp.container || !cp.container.classList.contains('open')) return; switch (event.key) { case ' ': case 'k': case 'K': event.preventDefault(); if (video.paused) video.play().catch(() => {}); else video.pause(); break; case 'ArrowLeft': App.player._doSkip('back'); break; case 'ArrowRight': App.player._doSkip('forward'); break; case 'ArrowUp': event.preventDefault(); video.volume = Math.min(1, video.volume + 0.1); video.muted = false; break; case 'ArrowDown': event.preventDefault(); video.volume = Math.max(0, video.volume - 0.1); break; case 'm': case 'M': video.muted = !video.muted; break; case 'Escape': App.player.close(); break; default: return; } wakeHud(); }; document.addEventListener('keydown', onKeyDown); addCleanup(() => document.removeEventListener('keydown', onKeyDown)); } // --------------------------------------------------------------------- // Close button + browser/system back // --------------------------------------------------------------------- function bindClose() { const closeBtn = q('.cp-close-btn'); if (closeBtn) { const onClick = (event) => { event.stopPropagation(); App.player.close(); }; closeBtn.addEventListener('click', onClick); addCleanup(() => closeBtn.removeEventListener('click', onClick)); } const onPopState = () => { if (cp.container && cp.container.classList.contains('open')) { cp.historyPushed = false; // the pushed state was just consumed by the browser App.player.close({ fromPopState: true }); } }; window.addEventListener('popstate', onPopState); addCleanup(() => window.removeEventListener('popstate', onPopState)); // Only push once per "session": open() tears down and rebinds on // reentrancy (see open()) without closing first, so a second open() // while already open must not stack a second history entry that // close()'s single history.back() could never fully unwind. if (!cp.historyPushed) { history.pushState({ customPlayerOpen: true }, '', location.href); cp.historyPushed = true; } } // --------------------------------------------------------------------- // Buffering + error UI // --------------------------------------------------------------------- function bindBufferingIndicator(video) { const spinner = q('.cp-spinner'); if (!spinner) return; const show = () => { spinner.classList.add('is-visible'); }; const hide = () => { spinner.classList.remove('is-visible'); }; video.addEventListener('waiting', show); video.addEventListener('playing', hide); video.addEventListener('canplay', hide); video.addEventListener('pause', hide); addCleanup(() => { video.removeEventListener('waiting', show); video.removeEventListener('playing', hide); video.removeEventListener('canplay', hide); video.removeEventListener('pause', hide); }); } function showBuffering(show) { const spinner = q('.cp-spinner'); if (spinner) spinner.classList.toggle('is-visible', show); } function showError(message, onRetry) { showBuffering(false); const errorEl = q('.cp-error'); const textEl = q('.cp-error-text'); const retryBtn = q('.cp-retry-btn'); if (!errorEl) return; if (textEl) textEl.textContent = message || 'Playback failed.'; errorEl.hidden = false; if (retryBtn) { retryBtn.onclick = (event) => { event.stopPropagation(); errorEl.hidden = true; onRetry(); }; } } function hideError() { const errorEl = q('.cp-error'); if (errorEl) errorEl.hidden = true; } // --------------------------------------------------------------------- // Source resolution + HLS/native fallback chain // --------------------------------------------------------------------- function resolveSources(videoData) { if (cp.formatOverride) { const source = App.videos.resolveSourceForFormat(videoData, cp.formatOverride); return source ? [source] : []; } if (App.videos && typeof App.videos.resolveStreamSources === 'function') { return App.videos.resolveStreamSources(videoData); } return []; } function playSources(videoData, opts) { const video = cp.video; const token = ++cp.attemptToken; const resumeAt = (opts && opts.resumeAt) || 0; // Captured once per call rather than read from the shared `cp` // object later: if open() is ever re-entered for a different video // before this attempt settles, cp.originEl will have moved on to the // new card, and a stale callback reading it live would either mark // the wrong card loaded or (via the token guard below) never clear // this card's spinner at all. const originEl = (opts && opts.originEl) || null; const sources = resolveSources(videoData); const clearLoading = () => { if (originEl) originEl.classList.remove('is-loading'); }; if (!sources.length) { clearLoading(); showError('Unable to play this stream.', () => playSources(videoData, opts)); return; } const directProven = (url) => !!(App.videos && App.videos.isDirectProven && App.videos.isDirectProven(url)); const plan = []; sources.forEach((resolved) => { if (directProven(resolved.url)) plan.push({ resolved, direct: true }); plan.push({ resolved, direct: false }); }); const attempt = async (index) => { if (token !== cp.attemptToken) return; const entry = plan[index]; const resolved = entry.resolved; const hasNext = index + 1 < plan.length; let playbackStarted = false; let settled = false; const advanceOrFail = (message) => { if (settled || token !== cp.attemptToken) return; settled = true; if (hasNext) attempt(index + 1); else { clearLoading(); showError(message, () => playSources(videoData, opts)); } }; let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved); let isHls = /\.m3u8($|\?)/i.test(resolved.url); let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url); if (resolved.isLive) { isHls = true; isDirectMedia = false; } video.onerror = null; if (state.hlsPlayer) { state.hlsPlayer.stopLoad(); state.hlsPlayer.detachMedia(); state.hlsPlayer.destroy(); state.hlsPlayer = null; } video.pause(); video.removeAttribute('src'); video.load(); if (!isHls && !entry.direct) { try { const headResp = await fetch(streamUrl, { method: 'HEAD' }); if (token !== cp.attemptToken) return; const contentType = headResp.headers.get('Content-Type') || ''; if (contentType.includes('application/vnd.apple.mpegurl')) isHls = true; else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) isDirectMedia = true; } catch (err) { // Best-effort sniff only. } } const startPlayback = () => { if (playbackStarted) return; playbackStarted = true; clearLoading(); hideError(); showBuffering(false); if (resumeAt > 0) { const seek = () => { try { video.currentTime = resumeAt; } catch (err) { /* ignore */ } }; if (video.readyState >= 1) seek(); else video.addEventListener('loadedmetadata', seek, { once: true }); } const p = video.play(); if (p && p.catch) p.catch(() => {}); }; if (!window.Hls && (isHls || !isDirectMedia)) { try { await App.ensureHls(); } catch (err) { /* fall back to native below */ } if (token !== cp.attemptToken) return; } const canUseHls = !!(window.Hls && window.Hls.isSupported()); const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl')); let hlsTried = false; let nativeTried = false; let usingHls = false; const startNative = () => { if (nativeTried) return; nativeTried = true; usingHls = false; video.src = streamUrl; startPlayback(); }; const startHls = (allowFallback) => { if (!canUseHls || hlsTried) return false; hlsTried = true; usingHls = true; state.hlsPlayer = new window.Hls(); state.hlsPlayer.loadSource(streamUrl); state.hlsPlayer.attachMedia(video); state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, () => startPlayback()); startPlayback(); state.hlsPlayer.on(window.Hls.Events.ERROR, (event, data) => { if (data && data.fatal) { const shouldFallback = allowFallback && !nativeTried && !isHls; if (state.hlsPlayer) { state.hlsPlayer.destroy(); state.hlsPlayer = null; } if (shouldFallback) { startNative(); return; } advanceOrFail('Unable to play this stream.'); } }); return true; }; if (prefersHls) { if (!startHls(true)) { if (video.canPlayType('application/vnd.apple.mpegurl')) startNative(); else advanceOrFail('HLS is not supported in this browser.'); } } else { startNative(); } video.onerror = () => { if (!usingHls && canUseHls && !hlsTried && !isDirectMedia) { if (startHls(true)) return; } advanceOrFail('Video failed to load.'); }; }; showBuffering(true); attempt(0); } // --------------------------------------------------------------------- // Public API // --------------------------------------------------------------------- App.player.open = function(source, opts) { // Reentrant call (a second video opened before the first settled): // tear down the previous session's video/listeners in place, but // don't pop the history entry bindClose() already pushed for it -- // it's reused below instead of stacking a second one that close()'s // single history.back() could never fully unwind. Also clears the // abandoned session's own loading spinner, since its card would // otherwise never hear about the takeover. const reopening = !!(cp.container && cp.container.classList.contains('open')); if (reopening) { cp.attemptToken++; if (state.hlsPlayer) { state.hlsPlayer.destroy(); state.hlsPlayer = null; } if (cp.video) { cp.video.onerror = null; cp.video.pause(); } clearIdleTimer(); if (cp.originEl) cp.originEl.classList.remove('is-loading'); } runCleanups(); cp.originEl = opts && opts.originEl ? opts.originEl : null; if (cp.originEl) cp.originEl.classList.add('is-loading'); cp.container = buildContainer(); cp.video = q('.cp-video'); cp.formatOverride = null; const isLive = !!(source && typeof source === 'object' && (source.isLive || (source.meta && source.meta.isLive))); cp.container.classList.toggle('is-live', isLive); const titleText = q('.cp-title-text'); if (titleText) { titleText.textContent = (source && (source.title || (source.meta && source.meta.title))) || ''; requestAnimationFrame(measureTitle); } // Ambient backdrop: a blurred copy of the poster fills any letterbox // bars behind the contained video. const surface = q('.cp-surface'); if (surface) { let poster = (source && (source.thumb || (source.meta && (source.meta.thumbnail || source.meta.thumb)))) || ''; if (!poster && cp.originEl) { const img = cp.originEl.querySelector('img'); if (img) poster = img.currentSrc || img.src || ''; } if (poster) surface.style.setProperty('--poster', `url("${poster.replace(/"/g, '%22')}")`); else surface.style.removeProperty('--poster'); } bindFavorite(source); bindTimeline(cp.video); bindTransport(cp.video); addCleanup(App.customPlayer.bindFormatMenu(q('.cp-format-btn'), q('.cp-format-menu'), source, (fmt) => { cp.formatOverride = fmt; const resumeAt = cp.video.currentTime || 0; playSources(source, { resumeAt, originEl: cp.originEl }); })); bindGestures(cp.video); bindKeyboard(cp.video); bindClose(); bindBufferingIndicator(cp.video); cp.container.classList.add('open'); cp.container.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; wakeHud(); playSources(source, { originEl: cp.originEl }); }; App.player.close = function(opts) { if (!cp.container || !cp.container.classList.contains('open')) return; cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks if (state.hlsPlayer) { state.hlsPlayer.destroy(); state.hlsPlayer = null; } if (cp.video) { cp.video.onerror = null; cp.video.pause(); cp.video.removeAttribute('src'); cp.video.load(); } clearIdleTimer(); runCleanups(); cp.container.classList.remove('open', 'cp-hud-idle', 'is-live'); cp.container.style.transform = ''; cp.container.style.opacity = ''; cp.container.setAttribute('aria-hidden', 'true'); document.body.style.overflow = 'auto'; if (cp.historyPushed && !(opts && opts.fromPopState)) { cp.historyPushed = false; history.back(); } else { cp.historyPushed = false; } if (cp.originEl) { cp.originEl.classList.remove('is-loading'); cp.originEl = null; } cp.data = null; cp.formatOverride = null; }; })();