import { useSyncExternalStore } from "react"; /** * 当前主输入是触屏还是鼠标,写在 上,CSS 用 `touch:` 变体读它, * React 用 useTouchInput() 读它。 * * 只看 (pointer: coarse) 不够:车机、触屏电视盒、一体机这类设备常跑桌面 Linux / 桌面版 Chromium, * 报告的是精细指针和可悬停(按 PC 处理),实际只有触屏,悬停展开、音量簇、点按呼出控制条就全错了。 * 通用判断分三层,前一层认不出时后一层兜底: * * 1. 系统 / 浏览器报告:粗指针,或有触点却没有能悬停的指针 → 触屏。UA 只作提示(已知车机直接从触屏起步)。 * 2. 实际输入: * - 手指 / 笔按下(pointerType touch / pen)→ 触屏; * - 触屏被系统模拟成鼠标时(Linux 车机常见),按下前没有移动轨迹、光标「瞬移」到按下位置; * 连续两次瞬移按下 → 触屏; * - 真鼠标连续移动一段 → 回到鼠标(带触屏的笔记本、平板接鼠标)。 * 3. 手动:地址带 ?input=touch|mouse|auto,记在 localStorage,优先于自动判断。只作排障口子,界面上不给用户选。 * * 迁自 uhd_web app/lib/input-modality.ts(2026-10-08)。 */ export type InputMode = "touch" | "mouse"; export type InputPreference = "auto" | InputMode; const STORAGE_KEY = "umt-input-override"; // 已知跑桌面浏览器内核的车机 / 车载系统 UA 片段(只作初始提示,不锁定) const TOUCH_UA_HINTS = /\b(Tesla|QtCarBrowser|Automotive|AAOS|MBUX|Rivian|Lucid|Polestar|NIO|XPENG|LiAuto|HarmonyOS.*Car)\b/i; // 真鼠标点击前的移动是连续的小步。瞬移判定:按下点离上一次移动位置超过这个距离, // 或按下前紧挨着的那次移动本身就是一次大跳(触屏模拟的鼠标会先把光标跳到按下点再按下)。 // 原地不动再点不算:电脑用户手放着连点两下不能被误判成触屏 const TELEPORT_PX = 24; const JUMP_BEFORE_DOWN_MS = 120; const TELEPORTS_TO_TOUCH = 2; // 连续这么多次小步移动才算真鼠标回来了(模拟鼠标在按下前通常只有 0~1 次移动) const STEADY_MOVES_TO_MOUSE = 6; let auto: InputMode = "mouse"; let pref: InputPreference = "auto"; let started = false; const listeners = new Set<() => void>(); function effective(): InputMode { return pref === "auto" ? auto : pref; } function publish() { document.documentElement.dataset.input = effective(); for (const fn of listeners) fn(); } function setAuto(next: InputMode) { if (next === auto) return; const before = effective(); auto = next; if (effective() !== before) publish(); } function mq(q: string) { return window.matchMedia?.(q).matches ?? false; } function detect(): InputMode { if (mq("(pointer: coarse)")) return "touch"; if (navigator.maxTouchPoints > 0 && !mq("(any-hover: hover)")) return "touch"; if (TOUCH_UA_HINTS.test(navigator.userAgent)) return "touch"; return "mouse"; } function readPreference(): InputPreference { try { const fromUrl = new URLSearchParams(window.location.search).get("input"); if (fromUrl === "auto" || fromUrl === "touch" || fromUrl === "mouse") { localStorage.setItem(STORAGE_KEY, fromUrl); return fromUrl; } const saved = localStorage.getItem(STORAGE_KEY); if (saved === "touch" || saved === "mouse") return saved; } catch { // 隐私模式等拿不到存储时按自动处理 } return "auto"; } /** 在客户端入口调用一次;重复调用无副作用 */ export function initInputModality() { if (started || typeof window === "undefined") return; started = true; pref = readPreference(); auto = detect(); document.documentElement.dataset.input = effective(); let lastMove: { x: number; y: number; t: number; jumped: boolean } | null = null; let teleports = 0; let steadyMoves = 0; window.addEventListener( "pointerdown", (event) => { if (event.pointerType === "touch" || event.pointerType === "pen") { teleports = 0; steadyMoves = 0; setAuto("touch"); return; } if (event.pointerType !== "mouse") return; const jumped = !lastMove || Math.hypot(event.clientX - lastMove.x, event.clientY - lastMove.y) > TELEPORT_PX || (lastMove.jumped && event.timeStamp - lastMove.t < JUMP_BEFORE_DOWN_MS); steadyMoves = 0; if (jumped) { teleports += 1; if (teleports >= TELEPORTS_TO_TOUCH) setAuto("touch"); } else { teleports = 0; } }, { capture: true, passive: true }, ); window.addEventListener( "pointermove", (event) => { if (event.pointerType !== "mouse") return; const prev = lastMove; const step = prev ? Math.hypot(event.clientX - prev.x, event.clientY - prev.y) : Infinity; lastMove = { x: event.clientX, y: event.clientY, t: event.timeStamp, jumped: step > TELEPORT_PX }; // 按住拖动(滑动进度条、触屏拖拽被模拟成鼠标)不算「真鼠标回来了」 if (event.buttons !== 0 || !prev) return; if (step > 0 && step <= TELEPORT_PX && event.timeStamp - prev.t < 200) { steadyMoves += 1; if (steadyMoves >= STEADY_MOVES_TO_MOUSE) { teleports = 0; setAuto("mouse"); } } else { steadyMoves = 0; } }, { capture: true, passive: true }, ); window.matchMedia?.("(pointer: coarse)").addEventListener("change", () => setAuto(detect())); } export function getInputMode(): InputMode { return effective(); } /** * 这次指针事件按触屏还是鼠标处理。手指 / 笔一律算触屏;鼠标类型的事件在全站判定为触屏模式时 * 也算触屏 —— 车机等把触屏报告成鼠标的设备,播放器手势、进度条、点按显隐靠它才能生效。 */ export function pointerKind(event: { pointerType: string }): InputMode { if (event.pointerType === "touch" || event.pointerType === "pen") return "touch"; return effective(); } function subscribe(fn: () => void) { listeners.add(fn); return () => listeners.delete(fn); } /** 主输入是否为触屏。SSR 阶段返回 false,水合后按实际校正 */ export function useTouchInput(): boolean { return useSyncExternalStore( subscribe, () => effective() === "touch", () => false, ); }