The quality menu now marks the format that is actually on screen when it opens, read live from the player rather than recorded at bind time, so the tick follows an automatic pick or a fallback after a failed candidate, not only a manual choice. Labels drop the container (mp4 told the viewer nothing about a quality choice) and gain the extractor's format_note when it says something the quality doesn't already. The menu sits outside .cp-hud so it can escape the bar's overflow, which means the idle fade never reached it -- the player and the reels feed now close it along with the rest of the HUD. Opening it restarts the idle countdown (the feed's window is only a second), and on desktop a mouse resting on the open menu holds the HUD up, same as the bars. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
339 lines
15 KiB
JavaScript
339 lines
15 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) : ''}`);
|
|
// 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) =>
|
|
`<button class="cp-format-option" type="button" role="menuitemradio" aria-checked="false" data-index="${i}">${opt.label}</button>`
|
|
).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).
|
|
// -----------------------------------------------------------------
|
|
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);
|
|
};
|
|
};
|
|
})();
|