/* ============================================================================
* dsh-mornye-quota-pet —— 莫宁额度桌宠(页面客户端,原生 JS 零依赖)
*
* 数据:GET /dsh-mornye-pet/usage.json → {windows:{rolling|weekly|monthly:{used,resetsAt}}}
* 请求失败/加载中时不播随机台词;断线时沿用最近数据并标记 stale。
* 演示:URL 带 ?mock=1 时使用本地 mock 数据(供 demo.html 双击预览)。
* ==========================================================================*/
(function () {
if (window.__dshMornyePet) return
window.__dshMornyePet = true
var DEMO = (window.MORN_RE_DEMO === true) || /(?:[?&]|#)mock=1/.test(location.search) || (function () {
// 也支持把 ?mock=1 挂在 script 标签上(如 demo.html 的 src="lib/widget.js?mock=1")
try {
var s = document.currentScript && document.currentScript.src
return !!s && /\bmock=1\b/.test(s)
} catch (e) { return false }
})()
var MIN_SCALE = 0.6
var MAX_SCALE = 2.5
var CLICK_SQ = 9
var REFRESH_MS = 60000
var BUBBLE_MS = 5000
var RANDOM_MS = 6500
var FETCH_TIMEOUT_MS = 20000
var TICK_MS = 1000
var CHANGE_MS = 900
var ANIM_MS = 700
var API = {
usage: DEMO ? null : '/dsh-mornye-pet/usage.json',
config: DEMO ? null : '/dsh-mornye-pet/config.json',
sources: DEMO ? null : '/dsh-mornye-pet/sources.json',
pet: DEMO ? (window.MORN_RE_PET_DATA_URL || 'pet.png') : '/dsh-mornye-pet/pet.png',
press: DEMO ? null : '/dsh-mornye-pet/sound/press-',
release: DEMO ? null : '/dsh-mornye-pet/sound/release-'
}
var WINDOW_KEYS = [] // 档位键(由 /sources.json 驱动)
var WINDOW_LABEL = {}
var SHORT = {}
var DEMO_SOURCES = [
{ key: 'opencode:monthly', label: '每月额度', short: '月' },
{ key: 'opencode:weekly', label: '每周额度', short: '周' },
{ key: 'opencode:rolling', label: '滚动额度', short: '滚动' },
{ key: 'demo', label: '示例源 · 额度', short: '示例' },
]
function applySources(list) {
WINDOW_KEYS = []
WINDOW_LABEL = {}
SHORT = {}
var src = (Array.isArray(list) && list.length) ? list : DEMO_SOURCES
src.forEach(function (d) {
if (!d || !d.key) return
WINDOW_KEYS.push(d.key)
WINDOW_LABEL[d.key] = d.label || d.key
SHORT[d.key] = d.short || String(d.label || d.key).slice(0, 2)
})
buildWinRows()
if (!winOrder.length) winOrder = WINDOW_KEYS.slice()
sanitizeCur()
if (typeof rebuildSrcList === 'function') rebuildSrcList()
}
/* ---------------- 莫宁视觉:淡蓝白 + 灿金细线 + 冷静几何 ---------------- */
var css = [
'.dshmv-root{position:fixed;right:0;bottom:0;--dshmv-scale:1;--dshmv-base:clamp(150px,calc(min(280px,min(100vw,100vh) * 0.30) * var(--dshmv-scale)),680px);width:var(--dshmv-base);height:var(--dshmv-base);pointer-events:none;user-select:none;-webkit-user-select:none;z-index:9999;font-family:inherit;transition:left .16s ease,top .16s ease,transform .3s ease}',
'.dshmv-root.dshmv-left{transform:scaleX(-1)}',
'.dshmv-root.dshmv-dragging{cursor:grabbing;transition:none}',
'.dshmv-body{position:absolute;left:0;top:0;width:100%;height:100%;transform-origin:50% 100%;transition:transform .22s cubic-bezier(.34,1.56,.64,1)}',
'.dshmv-img{position:absolute;right:0;bottom:0;width:62%;height:auto;display:block;pointer-events:none;-webkit-user-drag:none;user-select:none;filter:drop-shadow(0 6px 14px rgba(61,80,143,.18))}',
'.dshmv-bubble{position:absolute;left:0;top:0;width:100%;aspect-ratio:1026/700;pointer-events:none;z-index:1}',
'.dshmv-bubble svg{display:block;width:100%;height:100%;pointer-events:none}',
'.dshmv-bubble svg path,.dshmv-bubble svg ellipse{pointer-events:none;cursor:pointer}',
'.dshmv-bubble.dshmv-bubble-open svg .dshmv-shape,.dshmv-bubble.dshmv-bubble-open svg .dshmv-b1,.dshmv-bubble.dshmv-bubble-open svg .dshmv-b2{pointer-events:visiblePainted}',
'.dshmv-bubble .dshmv-shape,.dshmv-bubble .dshmv-b1,.dshmv-bubble .dshmv-b2,.dshmv-bubble .dshmv-ringtrack,.dshmv-bubble .dshmv-ringarc,.dshmv-bubble .dshmv-dot{opacity:0;transform:scale(.7);transform-box:fill-box;transform-origin:50% 50%;transition:opacity .2s ease,transform .2s ease}',
'.dshmv-bubble.dshmv-bubble-open .dshmv-shape,.dshmv-bubble.dshmv-bubble-open .dshmv-b1,.dshmv-bubble.dshmv-bubble-open .dshmv-b2,.dshmv-bubble.dshmv-bubble-open .dshmv-ringtrack,.dshmv-bubble.dshmv-bubble-open .dshmv-ringarc,.dshmv-bubble.dshmv-bubble-open .dshmv-dot{opacity:1;transform:none}',
'.dshmv-bubble.dshmv-bubble-open .dshmv-b2{transition-delay:0s}',
'.dshmv-bubble.dshmv-bubble-open .dshmv-b1{transition-delay:.13s}',
'.dshmv-bubble.dshmv-bubble-open .dshmv-shape{transition-delay:.26s}',
'.dshmv-bubble.dshmv-bubble-open .dshmv-ringarc{transition-delay:.10s}',
'.dshmv-bubble.dshmv-bubble-open .dshmv-ringtrack{transition-delay:.10s}',
'.dshmv-bubble.dshmv-bubble-open .dshmv-dot{transition-delay:.16s}',
'.dshmv-bubble .dshmv-shape{transition-delay:.1s}',
'.dshmv-bubble .dshmv-b1{transition-delay:.2s}',
'.dshmv-bubble .dshmv-b2{transition-delay:.3s}',
'.dshmv-text{position:absolute;left:44.25%;top:38%;transform:translate(-50%,-50%);text-align:center;color:#3d5a8f;line-height:1.15;white-space:nowrap;--dshmv-u:calc(var(--dshmv-base) / 1026);pointer-events:none;opacity:0;transition:opacity .16s ease,transform .3s ease}',
'.dshmv-bubble.dshmv-bubble-open .dshmv-text{opacity:1;transition:opacity .16s ease .36s,transform .3s ease}',
'.dshmv-root.dshmv-left .dshmv-text{transform:translate(-50%,-50%) scaleX(-1)}',
'.dshmv-label{font-size:calc(var(--dshmv-u) * 58);font-weight:600;letter-spacing:.08em;color:#5b7299;max-width:calc(var(--dshmv-u) * 560);overflow:hidden;text-overflow:ellipsis}',
'.dshmv-amount{font-size:calc(var(--dshmv-u) * 116);font-weight:800;line-height:1.05;color:#3d5a8f}',
'.dshmv-amount.dshmv-wrap{font-size:calc(var(--dshmv-u) * 62);font-weight:700;line-height:1.3}',
'.dshmv-bubble.dshmv-hide-ring .dshmv-ringtrack,.dshmv-bubble.dshmv-hide-ring .dshmv-ringarc{opacity:0 !important;transform:scale(.7) !important}',
'.dshmv-hint{display:block;font-size:calc(var(--dshmv-u) * 50);color:#8aa0c4;letter-spacing:.02em;margin-top:calc(var(--dshmv-u) * 8);max-width:calc(var(--dshmv-u) * 600);white-space:normal;line-height:1.3;overflow-wrap:anywhere}',
'.dshmv-wrap{white-space:normal;max-width:calc(var(--dshmv-u) * 620);line-height:1.25}',
'.dshmv-menu-btn{position:absolute;top:40px;right:4px;width:26px;height:26px;border:none;border-radius:6px;background:rgba(61,90,143,.82);cursor:pointer;pointer-events:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;padding:0;z-index:2;opacity:0;transition:opacity .15s ease;color-scheme:light}',
'.dshmv-menu-btn.dshmv-menu-btn-visible{opacity:1;pointer-events:auto}',
'.dshmv-menu-btn span{display:block;width:14px;height:2px;background:#fff;border-radius:1px}',
'.dshmv-menu-btn:hover{background:#3d5a8f}',
'.dshmv-menu{position:fixed;min-width:286px;background:rgba(253,255,255,.96);border:1px solid rgba(201,166,90,.6);border-radius:10px;padding:10px 12px;opacity:0;transform:scale(.92) translateY(-4px);transform-origin:top right;transition:opacity .18s ease,transform .2s cubic-bezier(.34,1.56,.64,1);pointer-events:none;z-index:10000;box-shadow:0 6px 18px rgba(61,80,143,.16);color-scheme:light}',
'.dshmv-menu.dshmv-menu-open{opacity:1;transform:scale(1) translateY(0);pointer-events:auto}',
'.dshmv-menu-row{display:flex;align-items:center;gap:8px;margin:5px 0;color:#3d5a8f;font-size:12px;white-space:nowrap}',
'.dshmv-range{flex:1;min-width:0;accent-color:#c9a65a}',
'.dshmv-number{width:46px;border:1px solid rgba(61,90,143,.4);border-radius:6px;padding:2px 4px;font-size:12px;color:#3d5a8f;background:#fff}',
'.dshmv-sound{flex:1;border:1px solid rgba(61,90,143,.4);border-radius:6px;background:rgba(61,90,143,.06);color:#3d5a8f;font-size:12px;padding:3px 0;cursor:pointer}',
'.dshmv-sound:hover{background:rgba(61,90,143,.12)}',
'.dshmv-volpct{width:36px;text-align:right;color:#3d5a8f;font-size:12px}',
/* 菜单内直填自定义源 */
'.dshmv-srcbtn{flex:1;border:1px solid rgba(201,166,90,.6);border-radius:6px;background:#fff;color:#3d5a8f;font-size:12px;padding:4px 6px;cursor:pointer}',
'.dshmv-srcbtn:hover{background:rgba(201,166,90,.12)}',
'.dshmv-srcbtn-ghost{background:rgba(61,90,143,.06);border-color:rgba(61,90,143,.4)}',
'.dshmv-src-toggle{width:100%;text-align:left}',
'.dshmv-srcpanel{margin-top:6px;padding:6px 8px;border:1px dashed rgba(61,90,143,.35);border-radius:8px;background:rgba(61,90,143,.03)}',
'.dshmv-srcin{flex:1;min-width:0;border:1px solid rgba(61,90,143,.4);border-radius:6px;padding:3px 5px;font-size:12px;color:#3d5a8f;background:#fff}',
'.dshmv-srcstatus{font-size:11px;line-height:1.5;color:#3d5a8f;margin-top:4px;white-space:pre-wrap}',
'.dshmv-srclist{margin-top:2px}',
'.dshmv-srci{display:flex;align-items:center;gap:6px;font-size:12px;color:#3d5a8f;padding:2px 4px;border-radius:5px}',
'.dshmv-srci:hover{background:rgba(61,90,143,.06)}',
'.dshmv-src-del{margin-left:auto;border:none;background:none;color:#d04a4a;cursor:pointer;font-size:12px;padding:2px 4px}',
/* 额度勾选/拖拽排序 */
'.dshmv-winlist{display:flex;flex-direction:column;gap:2px;margin:2px 0 6px;width:100%}',
'.dshmv-winrow{display:flex;align-items:center;gap:6px;font-size:12px;color:#3d5a8f;padding:3px 5px;border-radius:5px;cursor:grab;background:rgba(61,90,143,.06)}',
'.dshmv-winrow.dshmv-winrow-off{opacity:.42;cursor:default;background:transparent}',
'.dshmv-winrow input{accent-color:#c9a65a;margin:0}',
'.dshmv-winrow .dshmv-drag{color:#c9a65a;font-size:10px;margin-left:auto}',
'.dshmv-wingroup{font-size:11px;font-weight:600;color:#8aa0c4;letter-spacing:.06em;margin:5px 2px 2px}',
/* 弹幕观察:悬浮在宠物上方的弹幕轮播(内容 DOM 构建,绝不用 innerHTML 拼用户文本) */
'.dshmv-danmaku{position:fixed;background:rgba(253,255,255,.93);border:1px solid rgba(201,166,90,.55);border-radius:10px;padding:6px 9px;box-shadow:0 4px 14px rgba(61,80,143,.16);z-index:9998;color:#3d5a8f;display:none;overflow:hidden}',
'.dshmv-danmaku-head{font-size:12px;font-weight:700;color:#5b7299;letter-spacing:.14em;margin-bottom:4px;border-bottom:1px dashed rgba(201,166,90,.4);padding-bottom:3px}',
'.dshmv-danmaku-row{font-size:13px;line-height:1.4;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
'.dshmv-danmaku-row b{color:#3d5a8f;font-weight:700}',
'.dshmv-danmaku-row.fresh{color:#d09a3f;animation:dshmvFlash 1.4s ease}',
'@keyframes dshmvFlash{0%{background:rgba(201,166,90,.4)}100%{background:transparent}}',
'.dshmv-danmaku-empty{font-size:12px;color:#8aa0c4}',
/* 三联仪表 */
'.dshmv-triplebox{position:absolute;left:44%;top:36%;width:62%;transform:translate(-50%,-50%);display:none;color:#3d5a8f;--dshmv-u:calc(var(--dshmv-base) / 1026);pointer-events:none}',
'.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox{display:block}',
'.dshmv-bubble.dshmv-mode-triple .dshmv-text{display:none}',
'.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open.dshmv-random .dshmv-triplebox{display:none}',
'.dshmv-bubble.dshmv-mode-triple.dshmv-random .dshmv-text{display:block}',
'.dshmv-root.dshmv-left .dshmv-triplebox{transform:translate(-50%,-50%) scaleX(-1)}',
'.dshmv-trow{display:flex;align-items:center;margin:calc(var(--dshmv-u) * 14) 0;white-space:nowrap}',
'.dshmv-tlabel{font-size:calc(var(--dshmv-u) * 46);color:#5b7299;min-width:56px;max-width:120px;text-align:left;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
'.dshmv-tbar{flex:1;height:calc(var(--dshmv-u) * 14);background:#e7edf9;border-radius:calc(var(--dshmv-u) * 7);overflow:hidden;margin:0 calc(var(--dshmv-u) * 10)}',
'.dshmv-tfill{display:block;height:100%;border-radius:inherit;transition:width .4s ease}',
'.dshmv-tpct{font-size:calc(var(--dshmv-u) * 46);font-weight:800;color:#3d5a8f;width:52px;text-align:right;flex-shrink:0}',
'.dshmv-treset{font-size:calc(var(--dshmv-u) * 32);color:#8aa0c4;width:82px;text-align:right;flex-shrink:0}',
/* 方案A:行数多 → 双列(4+)/紧凑(7+),永不缩字;用短标签一行式行,双列收起重置列 */
'.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid{display:grid;width:76%;grid-template-columns:1fr 1fr;column-gap:calc(var(--dshmv-u) * 14);row-gap:calc(var(--dshmv-u) * 2)}',
'.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid .dshmv-trow{margin:calc(var(--dshmv-u) * 4) 0}',
'.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid .dshmv-tlabel{min-width:0;max-width:none;padding-right:calc(var(--dshmv-u) * 8)}',
'.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid .dshmv-treset{display:none}',
'.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid.dshmv-tcompact .dshmv-trow{margin:calc(var(--dshmv-u) * 2) 0}',
'.dshmv-bubble.dshmv-mode-triple.dshmv-bubble-open .dshmv-triplebox.dshmv-tgrid.dshmv-tcompact .dshmv-tbar{height:calc(var(--dshmv-u) * 12)}',
/* 紧急告警角标 */
'.dshmv-badge{position:absolute;top:32%;right:30%;width:13px;height:13px;border-radius:50%;border:2px solid #fff;box-shadow:0 1px 4px rgba(61,80,143,.35);pointer-events:auto;cursor:pointer;z-index:3;display:none}',
'.dshmv-badge-warn{background:#d9b45b}',
'.dshmv-badge-danger{background:#d04a4a}'
].join('\n')
var styleEl = document.createElement('style')
styleEl.textContent = css
document.head.appendChild(styleEl)
var root = document.createElement('div')
root.className = 'dshmv-root'
var img = document.createElement('img')
img.className = 'dshmv-img'
img.src = API.pet
img.alt = '莫宁额度桌宠'
img.draggable = false
var menuBtn = document.createElement('button')
menuBtn.type = 'button'
menuBtn.className = 'dshmv-menu-btn'
menuBtn.title = '菜单'
menuBtn.innerHTML = ''
menuBtn.addEventListener('contextmenu', function (e) { e.stopPropagation(); e.preventDefault() })
menuBtn.addEventListener('click', function (e) {
e.stopPropagation()
var r = menuBtn.getBoundingClientRect()
toggleMenuAt(r.left + 6, r.bottom + 4)
})
var menuBox = document.createElement('div')
menuBox.className = 'dshmv-menu'
function menuLabel(text) {
var s = document.createElement('span'); s.textContent = text; return s
}
function menuRow() {
var r = document.createElement('div'); r.className = 'dshmv-menu-row'; return r
}
var scaleInput = document.createElement('input')
scaleInput.type = 'range'
scaleInput.min = String(MIN_SCALE)
scaleInput.max = String(MAX_SCALE)
scaleInput.step = '0.1'
scaleInput.className = 'dshmv-range'
scaleInput.value = '1.5'
var scaleNumber = document.createElement('input')
scaleNumber.type = 'number'
scaleNumber.min = '1'
scaleNumber.max = '20'
scaleNumber.step = '1'
scaleNumber.className = 'dshmv-number'
scaleNumber.value = '10'
scaleInput.addEventListener('pointerdown', function () { root.style.transition = 'none' })
scaleInput.addEventListener('input', function () { setScale(scaleInput.value) })
scaleInput.addEventListener('change', function () { root.style.transition = '' })
scaleNumber.addEventListener('change', function () {
var v = Math.round(Number(scaleNumber.value))
var s = MIN_SCALE + Math.max(0, Math.min(20, v) - 1) * (MAX_SCALE - MIN_SCALE) / 19
setScale(s)
root.style.transition = ''
})
var soundSelect = document.createElement('select')
soundSelect.className = 'dshmv-sound'
function soundOpt(value, label) {
var o = document.createElement('option'); o.value = value; o.textContent = label; return o
}
soundSelect.appendChild(soundOpt('tone', '仪器音'))
soundSelect.appendChild(soundOpt('chime', '鸣响音'))
soundSelect.appendChild(soundOpt('click', '按键音'))
soundSelect.appendChild(soundOpt('pop', '气泡音'))
soundSelect.appendChild(soundOpt('blip', '脉冲音'))
soundSelect.appendChild(soundOpt('thock', '叩击音'))
soundSelect.appendChild(soundOpt('off', '无声'))
soundSelect.addEventListener('change', function () { setSoundToggle(soundSelect.value) })
var volInput = document.createElement('input')
volInput.type = 'range'
volInput.min = '0'
volInput.max = '1'
volInput.step = '0.05'
volInput.className = 'dshmv-range'
volInput.value = '0.9'
var volPct = document.createElement('span')
volPct.className = 'dshmv-volpct'
volPct.textContent = '90%'
volInput.addEventListener('input', function () { setVol(volInput.value) })
var row1 = menuRow(); row1.appendChild(menuLabel('大小')); row1.appendChild(scaleInput); row1.appendChild(scaleNumber)
var row2 = menuRow(); row2.appendChild(menuLabel('音效')); row2.appendChild(soundSelect)
var row3 = menuRow(); row3.appendChild(menuLabel('音量')); row3.appendChild(volInput); row3.appendChild(volPct)
/* 显示样式:单档 / 三联仪表 */
var viewSelect = document.createElement('select')
viewSelect.className = 'dshmv-sound'
viewSelect.appendChild(soundOpt('single', '单档'))
viewSelect.appendChild(soundOpt('triple', '三联'))
viewSelect.addEventListener('change', function () {
viewMode = viewSelect.value === 'triple' ? 'triple' : 'single'
saveConfig()
render()
})
var rowSt = menuRow(); rowSt.appendChild(menuLabel('样式')); rowSt.appendChild(viewSelect)
/* 额度:勾选=显示/参与轮换;拖 ☰ 排序(第一位=默认显示) */
var winListWrap = document.createElement('div')
winListWrap.className = 'dshmv-winlist'
var winRowEls = {}
var dragSrc = null
function winRow(wk) {
var r = document.createElement('div')
r.className = 'dshmv-winrow'
r.setAttribute('data-win', wk)
r.draggable = true
var cb = document.createElement('input')
cb.type = 'checkbox'; cb.className = 'dshmv-wincheck'
cb.addEventListener('change', function () { toggleWin(wk) })
var lb = document.createElement('span'); lb.textContent = WINDOW_LABEL[wk]
var hd = document.createElement('span'); hd.className = 'dshmv-drag'; hd.textContent = '☰'
r.appendChild(cb); r.appendChild(lb); r.appendChild(hd)
r.addEventListener('dragstart', function (e) { dragSrc = wk; try { e.dataTransfer.setData('text/plain', wk); e.dataTransfer.effectAllowed = 'move' } catch (err) {} })
r.addEventListener('dragover', function (e) { if (dragSrc && dragSrc !== wk && winOrder.indexOf(wk) !== -1) { try { e.preventDefault(); e.dataTransfer.dropEffect = 'move' } catch (err) {} } })
r.addEventListener('drop', function (e) { if (dragSrc && dragSrc !== wk) { moveWinOrder(dragSrc, wk); try { e.preventDefault() } catch (err) {} } })
r.addEventListener('dragend', function () { dragSrc = null; refreshWinRows() })
winRowEls[wk] = r
return r
}
function buildWinRows() {
winListWrap.innerHTML = ''
var lastGroup = null
WINDOW_KEYS.forEach(function (k) {
var group = /^opencode:/.test(k) ? 'OpenCode Go' : null
if (group && group !== lastGroup) {
var h = document.createElement('div'); h.className = 'dshmv-wingroup'; h.textContent = group
winListWrap.appendChild(h); lastGroup = group
}
winListWrap.appendChild(winRow(k))
})
refreshWinRows()
}
function refreshWinRows() {
var order = winOrder.slice()
WINDOW_KEYS.forEach(function (k) { if (order.indexOf(k) === -1) order.push(k) })
order.forEach(function (k) { winListWrap.appendChild(winRowEls[k]) })
WINDOW_KEYS.forEach(function (k) {
var on = winOrder.indexOf(k) !== -1
var rowEl = winRowEls[k]
rowEl.classList.toggle('dshmv-winrow-off', !on)
rowEl.draggable = on
rowEl.querySelector('input').checked = on
})
}
function toggleWin(wk) {
var i = winOrder.indexOf(wk)
if (i !== -1) {
if (winOrder.length <= 1) { refreshWinRows(); return } // 至少保留 1 个
winOrder.splice(i, 1)
} else {
winOrder.push(wk)
}
sanitizeCur()
refreshWinRows()
saveConfig()
render()
}
function moveWinOrder(src, target) {
var s = winOrder.indexOf(src); var t = winOrder.indexOf(target)
if (s === -1 || t === -1 || s === t) { dragSrc = null; refreshWinRows(); return }
winOrder.splice(s, 1)
t = winOrder.indexOf(target)
winOrder.splice(t, 0, src)
refreshWinRows()
saveConfig()
render()
}
var rowQ = menuRow(); rowQ.appendChild(menuLabel('额度'))
menuBox.appendChild(row1); menuBox.appendChild(row2); menuBox.appendChild(row3)
menuBox.appendChild(rowSt)
menuBox.appendChild(rowQ)
menuBox.appendChild(winListWrap)
/* ---- 弹幕观察:开关在菜单;连本地桥接(127.0.0.1:3081)轮询最新弹幕,悬浮展示 ---- */
var danmakuOn = false
var danmakuTimer = null
var danmakuPanel = null
var danmakuList = []
var danmakuSeq = 0
var DMP_URL = DEMO ? null : 'http://127.0.0.1:3081/danmaku.json'
var rowD = menuRow(); rowD.appendChild(menuLabel('弹幕观察'))
var danmakuSwitch = document.createElement('input')
danmakuSwitch.type = 'checkbox'; danmakuSwitch.className = 'dshmv-wincheck'
danmakuSwitch.addEventListener('change', function () { setDanmaku(danmakuSwitch.checked) })
rowD.appendChild(danmakuSwitch)
menuBox.appendChild(rowD)
function setDanmaku(on) {
danmakuOn = !!on
danmakuSwitch.checked = danmakuOn
saveConfig()
if (danmakuOn) startDanmaku(); else stopDanmaku()
}
function ensureDanmakuPanel() {
if (danmakuPanel) return
danmakuPanel = document.createElement('div')
danmakuPanel.className = 'dshmv-danmaku'
var head = document.createElement('div'); head.className = 'dshmv-danmaku-head'; head.textContent = '弹幕观察'
var body = document.createElement('div'); body.className = 'dshmv-danmaku-body'; body.textContent = '更新中…'
danmakuPanel.appendChild(head); danmakuPanel.appendChild(body)
document.body.appendChild(danmakuPanel)
}
function startDanmaku() {
if (DEMO || !DMP_URL) return
ensureDanmakuPanel()
danmakuPanel.style.display = 'block'
danmakuSeq = 0
danmakuLoop()
positionDanmaku()
}
function stopDanmaku() {
if (danmakuTimer) { clearInterval(danmakuTimer); danmakuTimer = null }
if (danmakuPanel) danmakuPanel.style.display = 'none'
}
function danmakuLoop() {
if (danmakuTimer) clearInterval(danmakuTimer)
pollDanmaku()
danmakuTimer = setInterval(pollDanmaku, 1000)
}
function pollDanmaku() {
if (!danmakuOn || DEMO || !DMP_URL) return
fetch(DMP_URL + '?since=' + danmakuSeq, { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (d) {
if (!d || !d.ok || !d.messages || !d.messages.length) { positionDanmaku(); return }
d.messages.forEach(function (m) {
if (!m) return
danmakuList.unshift({ user: String(m.user || '?'), text: String(m.text || ''), ts: Number(m.ts) || 0 })
if (Number(m.ts) > danmakuSeq) danmakuSeq = Number(m.ts)
})
if (danmakuList.length > 8) danmakuList.length = 8
renderDanmaku()
positionDanmaku()
})
.catch(function (e) { window.__lastErr = 'danmaku:' + String((e && e.message) || e); positionDanmaku() })
}
function renderDanmaku() {
if (!danmakuPanel) return
var body = danmakuPanel.querySelector('.dshmv-danmaku-body')
body.className = 'dshmv-danmaku-body'
body.textContent = ''
if (!danmakuList.length) { body.className = 'dshmv-danmaku-empty'; body.textContent = '更新中…'; return }
var n = Math.min(4, danmakuList.length)
for (var i = 0; i < n; i++) {
var m = danmakuList[i]
var row = document.createElement('div')
row.className = 'dshmv-danmaku-row' + (i === 0 ? ' fresh' : '')
var b = document.createElement('b'); b.textContent = m.user
row.appendChild(b); row.appendChild(document.createTextNode(':' + m.text))
body.appendChild(row)
}
}
function positionDanmaku() {
if (!danmakuPanel) return
try {
var r = root.getBoundingClientRect()
var vp = viewport()
var pw = Math.round(Math.max(160, Math.min(300, r.width * 0.72)))
danmakuPanel.style.width = pw + 'px'
var x = state.h === 'left' ? r.left : r.right - pw
x = Math.min(Math.max(4, x), Math.max(4, vp.w - pw - 4))
var y = Math.max(4, r.top - danmakuPanel.offsetHeight - 8)
danmakuPanel.style.left = x + 'px'
danmakuPanel.style.top = y + 'px'
} catch (err) {}
}
/* ---- 自定义源:菜单内直填(POST /sources.json 热重载;仅生产模式) ---- */
var rebuildSrcList = null
if (!DEMO) {
var SRC_AUTH = [['none', '无密钥'], ['bearer', 'Bearer 头'], ['header', 'X-API-Key 头']]
var srcToggle = document.createElement('button')
srcToggle.type = 'button'; srcToggle.className = 'dshmv-srcbtn dshmv-src-toggle'; srcToggle.textContent = '+ 自定义源'
var srcPanel = document.createElement('div'); srcPanel.className = 'dshmv-srcpanel'; srcPanel.style.display = 'none'
function srcIn(ph) { var i = document.createElement('input'); i.className = 'dshmv-srcin'; i.placeholder = ph; return i }
function srcRow(label, el) { var r = menuRow(); r.appendChild(menuLabel(label)); r.appendChild(el); return r }
var srcNameI = srcIn('名称,如 Kimi 开放平台')
var srcUrlI = srcIn('接口 URL,https://…')
var srcTypeS = document.createElement('select'); srcTypeS.className = 'dshmv-sound'
srcTypeS.appendChild(soundOpt('used_percent', '给「已用百分比」'))
srcTypeS.appendChild(soundOpt('used_value', '给「已用量 + 上限」'))
srcTypeS.appendChild(soundOpt('remain_money', '给「剩余金额 + 总额」'))
var srcPathI = srcIn('数字路径,如 data.used')
var srcLimitI = srcIn('上限/总额路径(后两种要填)')
var srcLimitRow = srcRow('上限', srcLimitI)
var srcResetI = srcIn('重置时间路径(选填)')
var srcAuthS = document.createElement('select'); srcAuthS.className = 'dshmv-sound'
SRC_AUTH.forEach(function (a) { srcAuthS.appendChild(soundOpt(a[0], a[1])) })
var srcKeyI = srcIn('密钥引用名(填 key 的环境变量名)')
var srcStatus = document.createElement('div'); srcStatus.className = 'dshmv-srcstatus'
var srcTestB = document.createElement('button'); srcTestB.type = 'button'; srcTestB.className = 'dshmv-srcbtn dshmv-srcbtn-ghost'; srcTestB.textContent = '测试'
var srcAddB = document.createElement('button'); srcAddB.type = 'button'; srcAddB.className = 'dshmv-srcbtn'; srcAddB.textContent = '添加'
var srcBtnRow = menuRow(); srcBtnRow.appendChild(srcTestB); srcBtnRow.appendChild(srcAddB)
srcPanel.appendChild(srcRow('名称', srcNameI))
srcPanel.appendChild(srcRow('接口', srcUrlI))
srcPanel.appendChild(srcRow('类型', srcTypeS))
srcPanel.appendChild(srcRow('数字', srcPathI))
srcPanel.appendChild(srcLimitRow)
srcPanel.appendChild(srcRow('重置', srcResetI))
srcPanel.appendChild(srcRow('密钥方式', srcAuthS))
srcPanel.appendChild(srcRow('密钥引用', srcKeyI))
srcPanel.appendChild(srcBtnRow)
srcPanel.appendChild(srcStatus)
var srcListWrap = document.createElement('div'); srcListWrap.className = 'dshmv-srclist'
var srcHead = menuRow(); srcHead.appendChild(srcToggle)
menuBox.appendChild(srcHead)
menuBox.appendChild(srcListWrap)
menuBox.appendChild(srcPanel)
srcToggle.addEventListener('click', function () { srcPanel.style.display = srcPanel.style.display === 'none' ? '' : 'none'; syncLimitRow() })
srcTypeS.addEventListener('change', syncLimitRow)
function syncLimitRow() { srcLimitRow.style.display = (srcTypeS.value === 'used_percent') ? 'none' : '' }
syncLimitRow()
function srcPayload() {
return {
name: srcNameI.value.trim(),
url: srcUrlI.value.trim(),
mtype: srcTypeS.value,
path: srcPathI.value.trim(),
limit_path: srcLimitI.value.trim(),
reset_path: srcResetI.value.trim(),
key_ref: srcKeyI.value.trim(),
authType: srcAuthS.value,
}
}
function srcPost(action) {
return fetch(API.sources, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: action, source: srcPayload() }) })
.then(function (r) { return r.json() }).catch(function () { return { ok: false, error: '网络请求失败' } })
}
function srcFail(d) { srcStatus.textContent = '✗ ' + (d && d.error ? d.error : '失败') }
srcTestB.addEventListener('click', function () {
srcStatus.textContent = '测试中…'
srcPost('test').then(function (d) {
if (d && d.ok) srcStatus.textContent = '✓ 成功,已用 ' + d.used + '%' + (d.resetsAt ? ',重置 ' + String(d.resetsAt).slice(0, 10) : '')
else srcFail(d)
})
})
srcAddB.addEventListener('click', function () {
srcStatus.textContent = '添加中…'
srcPost('add').then(function (d) {
if (d && d.ok) {
srcStatus.textContent = '已添加 ✓ 见上方档位列表'
applySources(d.sources)
WINDOW_KEYS.forEach(function (k) { if (winOrder.indexOf(k) === -1) winOrder.push(k) })
sanitizeCur(); refreshWinRows(); saveConfig(); render(); rebuildSrcList(); refresh(true)
} else srcFail(d)
})
})
rebuildSrcList = function () {
srcListWrap.innerHTML = ''
WINDOW_KEYS.forEach(function (k) {
if (k.indexOf('opencode:') === 0) return
var r = document.createElement('div'); r.className = 'dshmv-srci'
var lb = document.createElement('span'); lb.textContent = WINDOW_LABEL[k] || k
var del = document.createElement('button'); del.type = 'button'; del.className = 'dshmv-src-del'; del.textContent = '✕'
r.appendChild(lb); r.appendChild(del)
srcListWrap.appendChild(r)
del.addEventListener('click', function () {
srcStatus.textContent = '删除中…'
fetch(API.sources, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'remove', id: k }) })
.then(function (r) { return r.json() }).catch(function () { return { ok: false, error: '网络请求失败' } })
.then(function (d) {
if (d && d.ok) {
// 先剔除 winOrder(否则 applySources 重建列表时会把刚删的键又贴回去)
winOrder = winOrder.filter(function (x) { return x !== k })
applySources(d.sources)
if (!winOrder.length) winOrder = WINDOW_KEYS.slice()
sanitizeCur(); refreshWinRows(); saveConfig(); render(); rebuildSrcList(); refresh(true)
srcPanel.style.display = 'none'
} else srcFail(d)
})
})
})
}
}
document.body.appendChild(menuBox)
/* ---- 气泡 SVG:淡蓝白卡片 + 灿金细线 + 剩余额度环 ---- */
var RING_CX = 454, RING_CY = 266, RING_R = 118
var RING_C = 2 * Math.PI * RING_R
function ringArc(wants) {
var arc = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
arc.setAttribute('class', 'dshmv-ringarc')
arc.setAttribute('cx', RING_CX); arc.setAttribute('cy', RING_CY); arc.setAttribute('r', RING_R)
arc.setAttribute('fill', 'none'); arc.setAttribute('stroke', '#c9a65a')
arc.setAttribute('stroke-width', '12'); arc.setAttribute('stroke-linecap', 'round')
arc.setAttribute('transform', 'rotate(-90 ' + RING_CX + ' ' + RING_CY + ')')
arc.setAttribute('stroke-dasharray', '0 ' + RING_C.toFixed(1))
arc.setAttribute('stroke-dashoffset', '0')
return arc
}
var ringEl = ringArc()
var textBox = document.createElement('div')
textBox.className = 'dshmv-text'
var labelEl = document.createElement('div')
labelEl.className = 'dshmv-label'
var amountEl = document.createElement('div')
amountEl.className = 'dshmv-amount'
var hintEl = document.createElement('div')
hintEl.className = 'dshmv-hint'
textBox.appendChild(labelEl); textBox.appendChild(amountEl); textBox.appendChild(hintEl)
var bubbleBox = document.createElement('div')
bubbleBox.className = 'dshmv-bubble'
/* 注:innerHTML 为完全静态的 SVG 模板(仅拼接数字常量 RING_CX/CY/R),
不含任何外部/用户数据,无注入面;动态变化的圆环用 createElementNS 独立追加。 */
bubbleBox.innerHTML =
''
bubbleBox.querySelector('svg').appendChild(ringEl)
bubbleBox.appendChild(textBox)
bubbleBox.addEventListener('click', function (e) {
e.stopPropagation()
if (!bubbleShown) return
if (randomActive) {
// 已在随机台词态:再点关闭
hideBubble()
return
}
bubbleClickCount++
if (bubbleClickCount === 1) {
// 第一次点击:切换窗口(月→周→滚动)
cycleWindow()
} else if (bubbleClickCount === 2) {
// 第二次点击:随机台词(若门控/加载态导致无可用台词,保持数据气泡并重试)
var lines = pickRandomLines()
if (!lines) {
bubbleClickCount--
refresh(true)
return
}
randomActive = true
randomLines = lines
swapBubbleContent(function () { render() })
} else {
hideBubble()
}
})
/* 三联仪表容器(A)+ 紧急告警角标(B) */
var tripleBox = document.createElement('div')
tripleBox.className = 'dshmv-triplebox'
bubbleBox.appendChild(tripleBox)
function shortReset(resetsAt) {
var s = secsToReset(resetsAt)
if (s === null) return '--'
if (s <= 0) return '已重置'
var d = Math.floor(s / 86400)
var h = Math.floor((s % 86400) / 3600)
if (d >= 1) return d + '天' + h + '时'
var m = Math.floor((s % 3600) / 60)
return h + '时' + m + '分'
}
function renderTriple() {
if (viewMode !== 'triple' || state.windows === null) return
/* 注:innerHTML 仅由数字/固定模板拼装(同气泡 SVG);标签取自自家 sources.json,无外部数据注入面 */
var rows = []
for (var i = 0; i < winOrder.length; i++) {
var k = winOrder[i]
var win = state.windows[k]
if (!win) continue
var used = typeof win.used === 'number' ? Math.round(win.used) : null
var remain = used === null ? null : Math.max(0, 100 - used)
var hcol = used === null ? '#c9a65a' : healthOf(remain).c
var barW = used === null ? 0 : Math.min(100, used) // 用量条 = 已用%(>100% 满条红示警)
rows.push({ k: k, used: used, hcol: hcol, barW: barW })
}
/* 布局:统一单列(短标签+大进度条+百分比,条最宽);仅 ≥7 档转双列紧凑 */
var grid = rows.length >= 7
tripleBox.classList.toggle('dshmv-tgrid', grid)
var s = ''
for (var j = 0; j < rows.length; j++) {
var r = rows[j]
s += '
' +
'' + (SHORT[r.k] || WINDOW_LABEL[r.k] || r.k) + '' +
'' +
'' + (r.used === null ? '--' : r.used + '%') + '' +
'
'
}
tripleBox.innerHTML = s
}
var badgeEl = document.createElement('div')
badgeEl.className = 'dshmv-badge'
var badgeAlarm = null
badgeEl.addEventListener('click', function (e) {
e.stopPropagation()
if (badgeAlarm) {
state.cur = badgeAlarm.k
shownWin = null; shownVal = null
sanitizeCur()
showBubble()
refresh(true)
}
})
root.appendChild(badgeEl)
function alarmState() {
var high = null, near = null
for (var i = 0; i < winOrder.length; i++) {
var k = winOrder[i]
var w = state.windows && state.windows[k]
if (!w) continue
if (/rolling/.test(k)) continue // 滚动档 5h 周期,禁用角标(否则恒亮金点)
if (high === null && typeof w.used === 'number' && w.used >= ALARM_USED) high = k
if (near === null) { var s = secsToReset(w.resetsAt); if (s !== null && s > 0 && s <= ALARM_RESET_SEC) near = k }
}
return high ? { k: high, badge: 'dshmv-badge-danger' } : (near ? { k: near, badge: 'dshmv-badge-warn' } : null)
}
function updateBadge() {
if (!badgeEl) return
var usable = state.status === 'ok' && state.windows !== null
var a = usable ? alarmState() : null
badgeAlarm = a
if (!a) { badgeEl.style.display = 'none'; return }
badgeEl.style.display = 'block'
badgeEl.className = 'dshmv-badge ' + a.badge
}
var body = document.createElement('div')
body.className = 'dshmv-body'
body.appendChild(img)
body.appendChild(bubbleBox)
root.appendChild(body)
root.appendChild(menuBtn)
document.body.appendChild(root)
/* ---------------- 状态 ---------------- */
var state = {
scale: 1.5,
h: 'right', hOff: 0,
v: 'bottom', vOff: 0,
left: 0, top: 0,
windows: null,
cur: null,
stale: false,
status: 'loading',
message: ''
}
/* 额度选项:启用档的有序列表(第一位=默认显示);viewMode: single 单档 / triple 三联 */
var winOrder = [] // 默认空,applySources 后 = 全部可用档;config 加载后可能被覆盖
var viewMode = 'single'
var ALARM_USED = 85 // 已用 ≥85% → 红点告警
var ALARM_RESET_SEC = 6 * 3600 // 距重置 <6h → 金点告警
// SHORT 已在顶部随 applySources 动态填充
function sanitizeCur() {
if (winOrder.indexOf(state.cur) === -1) state.cur = (winOrder[0] || null)
}
var busy = false
var settleTimer = null
var animDelayTimer = null
var drag = null
var shownWin = null
var shownVal = null
var animId = null
var bubbleShown = false
var bubbleTimer = null
var randomActive = false
var randomLines = null
var tickTimer = null
var talkTimer = null
var bubbleClickCount = 0
/* ---------------- 台词(定稿版,含时间门控) ---------------- */
var BUBBLE_STYLE_CLASS = { A: 'dshmv-label', B: 'dshmv-amount', C: 'dshmv-hint' }
function pickOne(arr) { return arr[Math.floor(Math.random() * arr.length)] }
function singleCenter(style, text, wrap) {
// 台词长句默认启用自动换行 + 适配字号,防止横向溢出气泡
return [null, { t: text, s: style, w: wrap === undefined ? true : !!wrap }, null]
}
function secsToReset(resetsAt) {
if (!resetsAt) return null
var t = new Date(resetsAt).getTime() - Date.now()
return Math.floor(t / 1000)
}
function anyNearReset(h) {
var win = state.windows && state.windows[h]
var s = win ? secsToReset(win.resetsAt) : null
return s !== null && s <= 24 * 3600
}
function maxUsed() {
var m = 0
if (state.windows) {
for (var k in state.windows) {
var u = state.windows[k].used
if (typeof u === 'number' && u > m) m = u
}
}
return m
}
function greetNow() {
var h = new Date().getHours() // 北京时间(本机时区);DSH 运行在本机
if (h >= 5 && h < 11) return '早上好'
if (h >= 11 && h < 13) return '中午好'
if (h >= 13 && h < 18) return '下午好'
if (h >= 18 && h < 23) return '晚上好'
return '夜深了'
}
function resetText(resetsAt) {
var s = secsToReset(resetsAt)
if (s === null) return '重置时间未知'
if (s <= 0) return '刚刚重置'
var d = Math.floor(s / 86400)
var h = Math.floor((s % 86400) / 3600)
var m = Math.floor((s % 3600) / 60)
var sec = s % 60
if (d >= 1) return '重置 ' + d + '天' + h + '小时后'
if (h >= 1) return '重置 ' + h + '小时' + String(m).padStart(2, '0') + '分后'
if (m >= 1) return '重置 ' + m + '分' + String(sec).padStart(2, '0') + '秒后'
return '即将重置'
}
/* 每组返回可选的台词条目(带 when 门控);返回 null 表示该条当前不成立 */
function group2() {
return [
{ w: 1, lines: function () { return singleCenter('B', '频率稳定,额度余量一切正常。') } },
{ w: 1, lines: function () { return singleCenter('B', '观测窗口已同步,数据实时更新中。') } },
{ w: 1, lines: function () { return singleCenter('B', '远程工位待命中——你的额度,我来盯着。') } },
{ w: 1, lines: function () { return singleCenter('B', '仪器读数正常,未发现异常波动。') } },
{ w: 1, lines: function () { return singleCenter('B', '观测报告:今日额度余额符合预期。') } },
{ w: 1, when: function () { return anyNearReset('monthly') || anyNearReset('weekly') || anyNearReset('rolling') || maxUsed() >= 60 },
lines: function () { return singleCenter('B', '放心,重置前我会一直守着这个窗口。') } },
]
}
function group3() {
return [
{ w: 1, lines: function () { return singleCenter('B', '连轴转可不行的……这次正好,把自己放个假。') } },
{ w: 1, lines: function () { return singleCenter('B', '频率流失那种检修,交给我就行。你只管好好写代码。') } },
{ w: 1, lines: function () { return singleCenter('B', '别担心,这种程度我自己就可以处理。唔……') } },
{ w: 1, lines: function () { return singleCenter('B', '想看星空的话,等你忙完,我陪你去做观星活动。') } },
{ w: 1, lines: function () { return singleCenter('B', '可以的话,记得给我带一袋多加糖的咖啡——') } },
{ w: 1, lines: function () { return singleCenter('B', '进度不急着赶,十五分钟的闲聊时间,我帮你看着。') } },
{ w: 1, lines: function () { return singleCenter('B', '好了,工作处理完了。' + greetNow() + ',前辈。') } },
{ w: 1, lines: function () { return [null, { t: '前辈……', s: 'B' }, null] } },
]
}
function group4() {
return [
{ w: 1, lines: function () { return singleCenter('B', '这么贵都不打折,前辈你说两句好听的呗。') } },
{ w: 1, lines: function () { return singleCenter('B', 'token 烧太快,月末要跟胖海豚一起吃土。') } },
{ w: 1, lines: function () { return singleCenter('B', '要不要我顺手给你画张趋势图?反正也是观测。') } },
{ w: 1, lines: function () { return singleCenter('B', '额度这东西,跟虚质磁暴一样,说不准哪天就爆了。') } },
{ w: 1, lines: function () { return singleCenter('B', '我看不懂人类的用量,但我大受震撼。') } },
]
}
function group5() {
return [
{ w: 1, lines: function () { return singleCenter('B', '蓝色星空之下,我们终会抵达天空海之外——') } },
{ w: 1, lines: function () { return singleCenter('B', '密码只能输入一次,强行破解的话……风险未知。') } },
{ w: 1, when: function () { return maxUsed() >= 90 },
lines: function () { return singleCenter('B', '根据教职工守则,超限复盘报告我是可以不写的——但会盯着前辈写完。') } },
{ w: 1, lines: function () { return singleCenter('B', '所以,糖加够的咖啡,永远是深夜最合适的思维补充剂。') } },
]
}
var GROUP_POOLS = [ { w: 12, g: group2 }, { w: 10, g: group3 }, { w: 6, g: group4 }, { w: 1, g: group5 } ]
function pickRandomLines() {
// 加载中 / 断线 / 失败态:禁播随机台词
if (state.status === 'loading' || state.status === 'error') return null
// 展平全部条目并做门控过滤
var entries = []
for (var i = 0; i < GROUP_POOLS.length; i++) {
var items = GROUP_POOLS[i].g()
for (var j = 0; j < items.length; j++) {
var it = items[j]
if (it.when && !it.when()) continue
entries.push({ w: GROUP_POOLS[i].w * it.w, lines: it.lines })
}
}
if (entries.length === 0) return null
var total = 0
for (var k = 0; k < entries.length; k++) total += entries[k].w
var r = Math.random() * total
for (var k2 = 0; k2 < entries.length; k2++) {
r -= entries[k2].w
if (r < 0) return entries[k2].lines()
}
return entries[entries.length - 1].lines()
}
/* ---------------- 渲染 ---------------- */
function healthOf(remain) {
if (remain >= 50) return { c: '#c9a65a', t: '#3d5a8f' }
if (remain >= 25) return { c: '#d09a3f', t: '#b57f2a' }
return { c: '#d04a4a', t: '#d04a4a' }
}
function fmtPct(n) {
return Math.round(n) + '%'
}
function currentWin() {
return state.windows && state.windows[state.cur] ? state.windows[state.cur] : null
}
function setRing(remain, color) {
if (!bubbleBox.classList.contains('dshmv-hide-ring')) {
var len = Math.max(0, Math.min(1, remain / 100)) * RING_C
ringEl.setAttribute('stroke', color)
ringEl.setAttribute('stroke-dasharray', len.toFixed(1) + ' ' + RING_C.toFixed(1))
}
}
/* C:主档 + 其余档小字速览 */
function glanceText() {
if (winOrder.length < 2 || viewMode === 'triple') return ''
var parts = []
for (var i = 0; i < winOrder.length; i++) {
var k = winOrder[i]
if (k === state.cur) continue
var w = state.windows && state.windows[k]
var u = w ? w.used : null
parts.push(SHORT[k] + ' ' + (typeof u === 'number' ? Math.round(u) + '%' : '--'))
}
return parts.length ? ' ' + parts.join(' · ') : ''
}
function fmtNum(n) {
var x = Number(n)
if (!Number.isFinite(x)) return '--'
return Number.isInteger(x) ? String(x) : String(Math.round(x * 100) / 100)
}
function buildHint(win) {
if (!win) return ''
var u = win.used
if (typeof u !== 'number') return '重置时间未知' + glanceText()
var h = '剩余 ' + fmtPct(100 - u) + ' · ' + resetText(win.resetsAt)
var ex = win.extra
if (ex && (ex.type === 'remain_money' || ex.type === 'used_value') && typeof ex.raw === 'number') {
var unit = typeof ex.unit === 'string' ? ex.unit : ''
h += ' · ' + (ex.type === 'remain_money' ? '剩' : '已用') + ' ' + unit + fmtNum(ex.raw)
if (typeof ex.limit === 'number') h += ' / ' + unit + fmtNum(ex.limit)
}
if (state.stale) h += ' · 旧数据'
return h + glanceText()
}
function render() {
sanitizeCur()
var randomShowing = !!(randomActive && randomLines)
if (randomShowing) {
bubbleBox.classList.remove('dshmv-mode-triple')
bubbleBox.classList.add('dshmv-random')
bubbleBox.classList.add('dshmv-hide-ring')
applyBubbleLines(randomLines)
updateBadge()
return
}
bubbleBox.classList.remove('dshmv-random')
if (viewMode === 'triple') {
renderTriple()
bubbleBox.classList.add('dshmv-mode-triple')
bubbleBox.classList.add('dshmv-hide-ring')
updateBadge()
return
}
bubbleBox.classList.remove('dshmv-mode-triple')
var win = currentWin()
var amount, hint, label = (WINDOW_LABEL[state.cur] || '额度') + ' · 已用', ringPct = null, health = { c: '#c9a65a', t: '#3d5a8f' }
if (state.status === 'error') {
amount = shownVal !== null && shownWin === state.cur ? fmtPct(shownVal) : '--'
hint = state.message ? String(state.message).slice(0, 12) : '获取失败 · 点击重试'
label = (WINDOW_LABEL[state.cur] || '额度') + ' · 已用'
} else if (!win || state.windows === null) {
amount = shownVal !== null && shownWin === state.cur ? fmtPct(shownVal) : '…'
hint = state.status === 'loading' ? '额度数据同步中…' : '等待数据…'
} else {
var used = win.used
var remain = typeof used === 'number' ? 100 - used : null
amount = remain !== null ? fmtPct(used) : '--'
hint = buildHint(win)
if (remain !== null) { ringPct = remain; health = healthOf(remain) }
}
labelEl.textContent = label
amountEl.textContent = amount
amountEl.style.color = health.t
setHint(hint)
if (ringPct === null) bubbleBox.classList.add('dshmv-hide-ring')
else { bubbleBox.classList.remove('dshmv-hide-ring'); setRing(ringPct, health.c) }
updateBadge()
}
var hintFadeTimer = null
var lastHintText = null
function setHint(text) {
if (text === lastHintText) return
lastHintText = text
if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
if (!bubbleShown) { hintEl.textContent = text; return }
hintEl.style.transition = 'opacity .18s ease'
hintEl.style.opacity = '0'
hintFadeTimer = setTimeout(function () {
hintFadeTimer = null
hintEl.textContent = text
hintEl.style.opacity = '1'
setTimeout(function () { hintEl.style.transition = ''; hintEl.style.opacity = '' }, 220)
}, 190)
}
var bubbleSwapTimer = null
function swapBubbleContent(applyFn) {
if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
textBox.style.transition = 'opacity .18s ease'
textBox.style.opacity = '0'
bubbleSwapTimer = setTimeout(function () {
bubbleSwapTimer = null
applyFn()
textBox.style.opacity = '1'
setTimeout(function () { textBox.style.transition = ''; textBox.style.opacity = '' }, 220)
}, 190)
}
function applyBubbleLines(lines) {
var els = [labelEl, amountEl, hintEl]
for (var i = 0; i < 3; i++) {
var el = els[i]
var ln = lines && lines[i]
if (ln) {
el.style.display = ''
el.className = (BUBBLE_STYLE_CLASS[ln.s] || 'dshmv-label') + (ln.w ? ' dshmv-wrap' : '')
el.textContent = ln.t
el.style.color = ln.s === 'B' ? '#3d5a8f' : ''
} else {
el.style.display = 'none'
el.textContent = ''
el.style.color = ''
}
}
}
function restoreLines() {
if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
lastHintText = null
textBox.style.transition = ''; textBox.style.opacity = ''
labelEl.style.display = ''
labelEl.className = 'dshmv-label'
labelEl.style.color = ''
amountEl.className = 'dshmv-amount'
amountEl.style.color = ''
hintEl.className = 'dshmv-hint'
hintEl.style.color = ''
render()
}
function showBubble() {
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
bubbleShown = true
randomActive = false
randomLines = null
bubbleClickCount = 0
restoreLines()
bubbleBox.classList.add('dshmv-bubble-open')
startTick()
bubbleTimer = setTimeout(hideBubble, BUBBLE_MS)
}
function hideBubble() {
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
if (bubbleSwapTimer) { clearTimeout(bubbleSwapTimer); bubbleSwapTimer = null }
if (hintFadeTimer) { clearTimeout(hintFadeTimer); hintFadeTimer = null }
stopTick()
textBox.style.transition = ''; textBox.style.opacity = ''
hintEl.style.transition = ''; hintEl.style.opacity = ''
randomActive = false
randomLines = null
bubbleShown = false
bubbleClickCount = 0
bubbleBox.classList.remove('dshmv-bubble-open')
}
/* 自言自语:随机台词自动弹出,不依赖用户点击;看完自己收起 */
function openRandomSpeak(lines) {
if (bubbleTimer) { clearTimeout(bubbleTimer); bubbleTimer = null }
bubbleShown = true
bubbleClickCount = 0
randomActive = true
randomLines = lines
bubbleBox.classList.add('dshmv-bubble-open')
render()
stopTick()
lastHintText = null
bubbleTimer = setTimeout(hideBubble, RANDOM_MS)
}
function scheduleTalk(first) {
if (talkTimer) { clearTimeout(talkTimer); talkTimer = null }
var delay = first ? (18000 + Math.random() * 22000) : (60000 + Math.random() * 90000)
talkTimer = setTimeout(function () {
talkTimer = null
// 空闲才开口:气泡未开、菜单未开、未在拖拽、数据正常
var can = !bubbleShown && !menuOpen && !(drag && drag.active) && state.status === 'ok'
if (can) {
var lines = pickRandomLines()
if (lines) openRandomSpeak(lines)
}
scheduleTalk(false)
}, delay)
}
function cycleWindow() {
if (winOrder.length < 2) return
var i = winOrder.indexOf(state.cur)
state.cur = winOrder[(i + 1) % winOrder.length]
shownWin = null
shownVal = null
swapBubbleContent(function () {
lastHintText = null
render()
})
refresh(false)
}
/* 倒计时秒级刷新(气泡开着且未进随机台词态时) */
function startTick() {
stopTick()
tickTimer = setInterval(function () {
if (!bubbleShown) { stopTick(); return }
if (randomActive) return
lastHintText = null
if (viewMode === 'triple') { renderTriple(); updateBadge(); return }
var win = currentWin()
if (win && typeof win.used === 'number') hintEl.textContent = buildHint(win)
updateBadge()
}, TICK_MS)
}
function stopTick() {
if (tickTimer) { clearInterval(tickTimer); tickTimer = null }
}
/* ---------------- 位置 / 吸附 / 缩放(沿用小鲸鱼成熟机制) ---------------- */
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v) }
function viewport() {
return { w: window.innerWidth || document.documentElement.clientWidth || 1280, h: window.innerHeight || document.documentElement.clientHeight || 800 }
}
function animateAmount(to, duration) {
var from = (shownWin === state.cur && shownVal !== null) ? shownVal : to
if (animId) cancelAnimationFrame(animId)
if (!isFinite(from)) from = to
if (from === to) { shownVal = to; shownWin = state.cur; amountEl.textContent = fmtPct(to); return }
var startTime = null
function step(ts) {
if (startTime === null) startTime = ts
var t = Math.min(1, (ts - startTime) / duration)
var eased = 1 - Math.pow(1 - t, 3)
var val = from + (to - from) * eased
amountEl.textContent = fmtPct(val)
if (t < 1) { animId = requestAnimationFrame(step) }
else { animId = null; shownVal = to; shownWin = state.cur; amountEl.textContent = fmtPct(to) }
}
animId = requestAnimationFrame(step)
}
function express() {
root.style.right = 'auto'
root.style.bottom = 'auto'
root.style.left = state.left + 'px'
root.style.top = state.top + 'px'
root.classList.toggle('dshmv-left', state.h === 'left')
}
function settle() {
var vp = viewport()
var w = root.offsetWidth || root.getBoundingClientRect().width || 0
var h = root.offsetHeight || root.getBoundingClientRect().height || 0
if (drag && drag.active) {
state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
express(); return
}
if (state.h === 'right') state.left = Math.max(0, vp.w - w - state.hOff)
else if (state.h === 'left') state.left = state.hOff
else state.left = clamp(state.left, 0, Math.max(0, vp.w - w))
if (state.v === 'bottom') state.top = Math.max(0, vp.h - h - state.vOff)
else if (state.v === 'top') state.top = state.vOff
else state.top = clamp(state.top, 0, Math.max(0, vp.h - h))
express()
}
/* ---------------- 数据刷新 ---------------- */
var mockBase = null
function mockFeed() {
// 演示模式:贴合真实值的固定数值(月度 29% / 每周 42% / 滚动 2% / 示例源 37.5%),
// 只在首次载入生成一次、之后稳定——仅重置倒计时随时间走,数字不跳(区分真实变化)。
if (!mockBase) {
var once = Date.now()
mockBase = {
'opencode:monthly': { used: 29, resetInMs: 19 * 86400000 + 5 * 3600000 },
'opencode:weekly': { used: 42, resetInMs: 3 * 86400000 + 22 * 3600000 },
'opencode:rolling': { used: 2, resetInMs: 5 * 3600000 },
demo: { used: 37.5, resetInMs: 18 * 3600000 },
once: once
}
}
function win(k) {
var b = mockBase[k]
return { used: b.used, resetsAt: new Date(mockBase.once + b.resetInMs).toISOString() }
}
var windows = {}
WINDOW_KEYS.forEach(function (k) { if (mockBase[k]) windows[k] = win(k) })
return { ok: true, fetchedAt: Date.now(), windows: windows }
}
function refresh(manual) {
if (busy) return
busy = true
if (animDelayTimer) { clearTimeout(animDelayTimer); animDelayTimer = null }
if (manual || state.windows === null) { state.status = 'loading'; render() }
function done(data) {
var ok = data && data.ok
if (ok) {
state.windows = data.windows || state.windows
state.stale = !!data.stale
state.message = ''
var win = currentWin()
var newVal = win && typeof win.used === 'number' ? win.used : null
var changed = shownVal !== null && shownWin === state.cur && newVal !== null && newVal !== shownVal
if (changed) {
if (!manual) {
showBubble()
state.status = 'ok'
if (animDelayTimer) clearTimeout(animDelayTimer)
animDelayTimer = setTimeout(function () {
animDelayTimer = null
animateAmount(newVal, ANIM_MS)
render()
}, 300)
if (settleTimer) clearTimeout(settleTimer)
settleTimer = setTimeout(function () {
settleTimer = null
render()
}, CHANGE_MS + 300)
} else {
animateAmount(newVal, ANIM_MS)
state.status = 'ok'
render()
}
} else {
if (animId === null) { shownVal = newVal; shownWin = state.cur }
if (newVal !== null) { shownVal = newVal; shownWin = state.cur }
state.status = 'ok'
render()
}
} else {
// 瞬时失败且之前有数据 → 沿用旧值并标记 stale(不闪错误)
if (data && data.stale && state.windows) {
state.stale = true
state.message = ''
state.status = 'ok'
render()
} else {
state.status = 'error'
state.message = (data && data.error) ? String(data.error) : '获取失败'
render()
}
}
armRefreshLoop(); checkReset()
}
function fail() {
if (state.windows) {
state.stale = true
state.status = 'ok'
} else {
state.status = 'error'
state.message = '获取失败'
}
render()
armRefreshLoop()
}
if (DEMO) {
setTimeout(function () {
try { busy = false; done(mockFeed()) }
catch (e) { window.__lastErr = 'demo-tick:' + String((e && e.stack) || e) }
}, 250)
return
}
var ctrl = null
var timer = null
try { ctrl = new AbortController(); timer = setTimeout(function () { try { ctrl.abort() } catch (err) {} }, FETCH_TIMEOUT_MS) } catch (err) {}
fetch(API.usage, { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined })
.then(function (r) { return r.json() })
.then(done)
.catch(fail)
.finally(function () { busy = false; if (timer) clearTimeout(timer) })
}
/* ---------------- 重置仪式感 + 临近加速刷新 ---------------- */
var FAST_REFRESH_MS = 20000
var resetWatch = null
var refreshTimer = null
function nearReset() {
if (!state.windows) return false
for (var k in state.windows) {
var w = state.windows[k]; if (!w) continue
var s = secsToReset(w.resetsAt)
if (s !== null && s > 0 && s <= 3600) return true
}
return false
}
function armRefreshLoop() {
if (refreshTimer) { clearInterval(refreshTimer); refreshTimer = null }
refreshTimer = setInterval(function () { refresh(false) }, nearReset() ? FAST_REFRESH_MS : REFRESH_MS)
}
function checkReset() {
var now = Date.now()
if (!resetWatch) resetWatch = {}
for (var k in state.windows) {
var w = state.windows[k]; if (!w) continue
var r = w.resetsAt ? new Date(w.resetsAt).getTime() : null
var prev = resetWatch[k]
if (typeof prev === 'number' && prev > 0 && prev <= now && r !== null && r !== prev) {
resetWatch[k] = r
ceremonyReset(k)
return
}
resetWatch[k] = r
}
}
function ceremonyReset(key) {
if (DEMO) { resetWatch = {}; return }
stopTick()
showBubble()
lastHintText = null
var lines = ['额度刷新啦!', '重置完成,重新出发~', '新额度已到账 🎉']
var line = lines[Math.floor(Math.random() * lines.length)]
randomLines = singleCenter('B', line, true)
randomActive = true
render()
if (talkTimer) clearTimeout(talkTimer)
talkTimer = setTimeout(function () {
randomLines = null
randomActive = false
render()
scheduleTalk(true)
}, RANDOM_MS + 800)
refresh(true)
}
/* ---------------- 配置 / 音效 / 菜单 ---------------- */
var soundOn = true
var soundVol = 0.9
var soundSet = 'tone'
function saveConfig() {
try {
var payload = { scale: state.scale, sound: soundOn, vol: soundVol, soundSet: soundSet, windows: winOrder.slice(), viewMode: viewMode, h: state.h, hOff: state.hOff, v: state.v, vOff: state.vOff, danmaku: danmakuOn }
if (DEMO) {
try { localStorage.setItem('dshmp-demo-config', JSON.stringify(payload)) } catch (err) {}
return
}
fetch(API.config, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
} catch (err) {}
}
function scaleToDisplay(s) {
return Math.round((s - MIN_SCALE) / ((MAX_SCALE - MIN_SCALE) / 19)) + 1
}
function setScale(v) {
var next = Math.round(Math.min(MAX_SCALE, Math.max(MIN_SCALE, Number(v))) * 10) / 10
var rect = root.getBoundingClientRect()
var fx = state.h === 'left' ? rect.left : rect.right
var fy = rect.bottom
state.scale = next
root.style.setProperty('--dshmv-scale', String(next))
scaleInput.value = String(next)
scaleNumber.value = String(scaleToDisplay(next))
saveConfig()
var r2 = root.getBoundingClientRect()
var vp = viewport()
if (state.h === 'left') state.left = Math.min(Math.max(fx, 0), Math.max(0, vp.w - r2.width))
else state.left = Math.min(Math.max(fx - r2.width, 0), Math.max(0, vp.w - r2.width))
state.top = Math.min(Math.max(fy - r2.height, 0), Math.max(0, vp.h - r2.height))
express()
}
function setVol(v) {
var next = Math.round(Math.min(1, Math.max(0, Number(v))) * 100) / 100
soundVol = next
soundOn = next > 0
volInput.value = String(next)
volPct.textContent = Math.round(next * 100) + '%'
try { if (pressAudio) pressAudio.volume = next; if (releaseAudio) releaseAudio.volume = next } catch (err) {}
saveConfig()
}
function setSoundToggle(v) {
soundOn = v !== 'off'
if (soundOn) {
soundSet = v
applySoundSet()
}
soundSelect.value = soundOn ? soundSet : 'off'
if (!soundOn) { volInput.value = '0'; volPct.textContent = '0%' }
saveConfig()
}
var SQUISH = 'scaleY(0.88) scaleX(1.05)'
var pressAudio = null
var releaseAudio = null
var pressing = false
var pressEnded = false
var releasePlayed = false
var releaseTimer = null
function applySoundSet() {
if (DEMO || !API.press) return
try {
pressAudio = new Audio(API.press + soundSet + '.wav')
pressAudio.preload = 'auto'
pressAudio.volume = soundVol
releaseAudio = new Audio(API.release + soundSet + '.wav')
releaseAudio.preload = 'auto'
releaseAudio.volume = soundVol
} catch (err) {}
}
function playPress() {
if (!pressAudio || !soundOn) return
try {
if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null }
if (releaseAudio) { releaseAudio.pause(); releaseAudio.currentTime = 0 }
pressEnded = false
releasePlayed = false
pressAudio.onended = function () {
pressEnded = true
if (!pressing && !releasePlayed) playRelease()
}
pressAudio.currentTime = 0
var p = pressAudio.play()
if (p && typeof p.catch === 'function') p.catch(function () {})
} catch (err) {}
}
function playRelease() {
if (releasePlayed || !releaseAudio || !soundOn) return
releasePlayed = true
try {
releaseAudio.currentTime = 0
var p = releaseAudio.play()
if (p && typeof p.catch === 'function') p.catch(function () {})
} catch (err) {}
}
function pressDown() {
body.style.transform = SQUISH
pressing = true
playPress()
}
function pressUp() {
body.style.transform = 'scaleY(1) scaleX(1)'
pressing = false
if (pressEnded) { playRelease(); return }
var durKnown = false
var remainMs = 0
try {
var dur = pressAudio ? pressAudio.duration : 0
if (isFinite(dur) && dur > 0) { durKnown = true; remainMs = (dur - pressAudio.currentTime) * 1000 }
} catch (err) {}
if (durKnown) {
releaseTimer = setTimeout(function () { releaseTimer = null; playRelease() }, Math.max(0, remainMs - 100))
}
}
var menuOpen = false
function toggleMenuAt(x, y) {
menuOpen = !menuOpen
if (menuOpen) positionMenuAt(x, y)
menuBox.classList.toggle('dshmv-menu-open', menuOpen)
menuBtn.classList.toggle('dshmv-menu-btn-visible', menuOpen)
}
function closeMenu() {
menuOpen = false
menuBox.classList.remove('dshmv-menu-open')
menuBtn.classList.remove('dshmv-menu-btn-visible')
root.style.transition = ''
snapCheck()
}
function snapCheck() {
var rect = root.getBoundingClientRect()
var vp = viewport()
var w = rect.width, h = rect.height
var left = rect.left, top = rect.top
var centerX = left + w / 2, centerY = top + h / 2
var moved = false
if (centerX < vp.w / 4) { state.h = 'left'; state.hOff = 0; left = 0; moved = true }
else if (centerX > vp.w * 3 / 4) { state.h = 'right'; state.hOff = 0; left = vp.w - w; moved = true }
else { state.h = null; state.hOff = left }
if (centerY < vp.h / 4) { state.v = 'top'; state.vOff = 0; top = 0; moved = true }
else { state.v = 'bottom'; state.vOff = Math.max(0, vp.h - top - h) }
if (moved) { state.left = left; state.top = top; settle() }
}
function positionMenuAt(x, y) {
try {
var wOff = menuBox.offsetWidth || 176
var hOff = menuBox.offsetHeight || 120
var vp = viewport()
var left = Math.min(Math.max(0, x), Math.max(0, vp.w - wOff - 6))
var top = y + 10
if (top + hOff > vp.h - 6) top = Math.max(6, y - hOff - 10)
menuBox.style.left = left + 'px'
menuBox.style.right = 'auto'
menuBox.style.bottom = 'auto'
menuBox.style.top = top + 'px'
menuBox.style.transformOrigin = 'top left'
} catch (err) {}
}
/* ---------------- 点击判定(alpha 命中,精确到像素) ---------------- */
var hitCanvas = null
var hitReady = false
var hitW = 100, hitH = 100
function setupHitTest() {
try {
var probe = new Image()
probe.onload = function () {
try {
hitW = probe.naturalWidth || probe.width || 100
hitH = probe.naturalHeight || probe.height || 100
hitCanvas = document.createElement('canvas')
hitCanvas.width = hitW; hitCanvas.height = hitH
hitCanvas.getContext('2d').drawImage(probe, 0, 0)
hitReady = true
} catch (err) {}
}
probe.onerror = function () {}
probe.src = API.pet
} catch (err) {}
}
function isPetHit(e) {
if (!hitCanvas || !hitReady) return true
try {
var r = img.getBoundingClientRect()
if (!r || r.width <= 0 || r.height <= 0) return false
var lx = (e.clientX - r.left) / r.width * hitW
var ly = (e.clientY - r.top) / r.height * hitH
if (lx < 0 || ly < 0 || lx >= hitW || ly >= hitH) return false
if (state.h === 'left') lx = hitW - lx
var data = hitCanvas.getContext('2d').getImageData(Math.floor(lx), Math.floor(ly), 1, 1).data
return data[3] > 10
} catch (err) { return true }
}
function onDocPointerDown(e) {
if (e.target && e.target.closest) {
if (e.target.closest('.dshmv-bubble') || e.target.closest('.dshmv-menu') || e.target.closest('.dshmv-menu-btn')) return
}
if (menuOpen) { closeMenu(); return }
if (e.button !== 0 && e.pointerType === 'mouse') return
if (!isPetHit(e)) return
try { e.preventDefault(); e.stopPropagation() } catch (err) {}
var vp = viewport()
var rect = root.getBoundingClientRect()
drag = { active: true, startX: e.clientX, startY: e.clientY, origLeft: rect.left, origTop: rect.top, w: rect.width, h: rect.height, moved: false, vp: vp }
root.classList.add('dshmv-dragging')
pressDown()
setWidgetCursor('grabbing')
document.addEventListener('pointermove', onDocPointerMove, true)
document.addEventListener('pointerup', onDocPointerUp, true)
document.addEventListener('pointercancel', onDocPointerCancel, true)
document.addEventListener('click', onDocClickStopper, true)
}
function onDocPointerMove(e) {
if (!drag || !drag.active) return
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY
if (dx * dx + dy * dy >= CLICK_SQ) drag.moved = true
state.left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
state.top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
express()
}
function onDocPointerUp(e) { endDrag(e, true) }
function onDocPointerCancel(e) { endDrag(e, false) }
function onDocClickStopper(e) { try { e.preventDefault(); e.stopPropagation() } catch (err) {} }
document.addEventListener('pointerdown', onDocPointerDown, true)
var widgetCursor = ''
function setWidgetCursor(v) {
if (v !== widgetCursor) { widgetCursor = v; try { document.body.style.cursor = v } catch (err) {} }
}
function onDocPointerMoveCursor(e) {
if (drag && drag.active) { setWidgetCursor('grabbing'); return }
var over = isPetHit(e)
setWidgetCursor(over ? 'grab' : '')
}
document.addEventListener('pointermove', onDocPointerMoveCursor, true)
/* 右键在莫宁/气泡上出菜单;Esc 关闭 */
function onDocContext(e) {
var el = null
try { el = e.target } catch (err) {}
if (el && el.closest && el.closest('.dshmv-menu')) { try { e.preventDefault() } catch (err) {} return }
var onTarget = el && el.closest && (el.closest('.dshmv-bubble') || el.closest('.dshmv-menu-btn'))
if (!onTarget && !isPetHit(e)) return
try { e.preventDefault(); e.stopPropagation() } catch (err) {}
toggleMenuAt(e.clientX || 0, e.clientY || 0)
}
document.addEventListener('contextmenu', onDocContext, true)
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && menuOpen) closeMenu()
})
function endDrag(e, clickAllowed) {
if (!drag || !drag.active) return
drag.active = false
document.removeEventListener('pointermove', onDocPointerMove, true)
document.removeEventListener('pointerup', onDocPointerUp, true)
document.removeEventListener('pointercancel', onDocPointerCancel, true)
document.removeEventListener('click', onDocClickStopper, true)
pressUp()
root.classList.remove('dshmv-dragging')
setWidgetCursor(isPetHit(e) ? 'grab' : '')
if (clickAllowed && !drag.moved) { showBubble(); refresh(true); return }
var dx = e.clientX - drag.startX, dy = e.clientY - drag.startY
var left = clamp(drag.origLeft + dx, 0, Math.max(0, drag.vp.w - drag.w))
var top = clamp(drag.origTop + dy, 0, Math.max(0, drag.vp.h - drag.h))
var centerX = left + drag.w / 2, centerY = top + drag.h / 2
if (centerX < drag.vp.w / 4) { state.h = 'left'; state.hOff = 0 }
else if (centerX > drag.vp.w * 3 / 4) { state.h = 'right'; state.hOff = 0 }
else { state.h = null; state.hOff = left }
if (centerY < drag.vp.h / 4) { state.v = 'top'; state.vOff = 0 }
else if (centerY > drag.vp.h * 3 / 4) { state.v = 'bottom'; state.vOff = 0 }
else { state.v = null; state.vOff = top }
state.left = left; state.top = top
settle()
}
window.addEventListener('resize', function () { settle() })
/* ---------------- 初始化 ---------------- */
window.__lastErr = null
var rect0 = root.getBoundingClientRect()
state.left = rect0.left
state.top = rect0.top
try { express(); render(); applySoundSet(); setupHitTest() } catch (e) { window.__lastErr = 'init-blk:' + String((e && e.stack) || e) }
function loadConfig(d) {
if (d && typeof d.scale === 'number' && d.scale >= MIN_SCALE - 0.1 && d.scale <= MAX_SCALE + 0.1) {
state.scale = d.scale
root.style.setProperty('--dshmv-scale', String(d.scale))
scaleInput.value = String(d.scale)
scaleNumber.value = String(scaleToDisplay(d.scale))
settle()
}
if (d && typeof d.vol === 'number') {
soundVol = d.vol
soundOn = soundVol > 0
volInput.value = String(soundVol)
volPct.textContent = Math.round(soundVol * 100) + '%'
try { if (pressAudio) pressAudio.volume = soundVol; if (releaseAudio) releaseAudio.volume = soundVol } catch (err) {}
}
if (d && d.sound === false) soundOn = false
if (d && d.soundSet && ['tone', 'chime', 'click', 'pop', 'blip', 'thock'].indexOf(d.soundSet) !== -1) {
soundSet = d.soundSet
if (soundOn) applySoundSet()
}
/* 额度档位:勾选集合 + 显示顺序(第一位=默认);非法项剔除,至少保留 1 个 */
if (d && Array.isArray(d.windows) && d.windows.length) {
var next = []
d.windows.forEach(function (k) { if (WINDOW_KEYS.indexOf(k) !== -1 && next.indexOf(k) === -1) next.push(k) })
if (next.length) {
WINDOW_KEYS.forEach(function (k) { if (next.indexOf(k) === -1) next.push(k) })
winOrder = next
}
}
if (d && (d.viewMode === 'triple' || d.viewMode === 'single')) viewMode = d.viewMode
/* 位置记忆:恢复吸附方位/偏移(settle 换算回像素并落位) */
if (d && (d.h === 'left' || d.h === 'right' || d.h === null)) state.h = d.h
if (d && typeof d.hOff === 'number' && isFinite(d.hOff) && d.hOff >= 0) state.hOff = d.hOff
if (d && (d.v === 'top' || d.v === 'bottom' || d.v === null)) state.v = d.v
if (d && typeof d.vOff === 'number' && isFinite(d.vOff) && d.vOff >= 0) state.vOff = d.vOff
settle()
if (d && typeof d.danmaku === 'boolean') danmakuOn = d.danmaku
danmakuSwitch.checked = danmakuOn
if (danmakuOn && !DEMO) startDanmaku(); else stopDanmaku()
viewSelect.value = viewMode
refreshWinRows()
sanitizeCur()
soundSelect.value = soundOn ? soundSet : 'off'
}
function initConfig() {
try {
if (DEMO) {
applySources(DEMO_SOURCES)
try {
var raw = localStorage.getItem('dshmp-demo-config')
if (raw) loadConfig(JSON.parse(raw))
} catch (err) {}
refresh(false)
return
}
// 先拉源描述(决定档位/标签),再拉配置,再拉数据
fetch(API.sources, { cache: 'no-store' })
.then(function (r) { return r.json() })
.then(function (d) { applySources(d && d.sources) })
.catch(function () { applySources(null) })
.then(function () { return fetch(API.config, { cache: 'no-store' }).then(function (r) { return r.json() }) })
.then(loadConfig)
.catch(function () {})
.then(function () { refresh(false) })
} catch (e) { window.__lastErr = 'initConfig:' + String((e && e.stack) || e) }
}
initConfig()
scheduleTalk(true)
armRefreshLoop()
/* 调试句柄(生产无害) */
window.__dshmv = { state: state, DEMO: DEMO, mockFeed: mockFeed, render: render, refresh: refresh, API: API, ceremonyReset: ceremonyReset, armRefreshLoop: armRefreshLoop, checkReset: checkReset, nearReset: nearReset, danmakuList: danmakuList, setDanmaku: setDanmaku, pollDanmaku: pollDanmaku }
})()