<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>RhythmStudio — Full Editor & Play</title>
<style>
:root{--bg:#061528;--panel:#0d2033;--accent:#6ad1ff;--text:#eaf6ff;--good:#7ef29a;--warn:#ffb265;--bad:#ff6f6f;}
*{box-sizing:border-box}
body{margin:0;font-family:Inter, "Segoe UI", Roboto, Arial, sans-serif;background:linear-gradient(180deg,var(--bg),#02131b);color:var(--text)}
.header{display:flex;align-items:center;padding:10px 14px;gap:12px;border-bottom:1px solid rgba(255,255,255,0.04)}
.container{display:grid;grid-template-columns:420px 1fr;gap:12px;padding:12px}
.card{background:linear-gradient(180deg,var(--panel),#071b2b);padding:12px;border-radius:10px;border:1px solid rgba(255,255,255,0.03)}
.row{display:flex;gap:8px;align-items:center;margin-bottom:8px}
.small{font-size:13px;color:#9fb8d9}
.btn{background:#0e2b3f;border:1px solid #224d66;padding:6px 10px;border-radius:8px;color:var(--text);cursor:pointer}
.btn.primary{background:linear-gradient(180deg,#1a6ba1,#0f4b75)}
.input{background:#071a2b;border:1px solid #12324a;padding:6px;border-radius:6px;color:var(--text)}
canvas{width:100%;height:560px;border-radius:8px;background:linear-gradient(180deg,#021322,#051528);display:block}
.timeline{height:120px;background:#061726;border-radius:8px;margin-top:10px;position:relative;overflow:hidden}
.note-chip{position:absolute;padding:4px 6px;border-radius:6px;background:#12314f;color:#d7eaff;font-size:12px;border:1px solid rgba(255,255,255,0.03);cursor:pointer}
.inspector{margin-top:12px;padding:10px;border-radius:8px;background:#061a2b;border:1px solid rgba(255,255,255,0.03)}
.progressOuter{height:12px;background:#041826;border-radius:8px;border:1px solid #123;overflow:hidden}
.progressInner{height:100%;background:linear-gradient(90deg,#7be0ff,#7effb3);width:0%}
.bigJudge{position:absolute;left:50%;transform:translateX(-50%);font-weight:900;pointer-events:none}
.mono{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace}
.hidden{display:none}
.pill{background:rgba(255,255,255,0.02);padding:6px 8px;border-radius:8px;font-size:13px}
</style>
</head>
<body>

<div class="header">
  <div style="font-weight:800">RhythmStudio — Full Editor & Play</div>
  <div style="flex:1"></div>
  <div class="small">Mode</div>
  <select id="modeSelect" class="input"><option value="editor">Editor / Test</option><option value="play">Play</option></select>
  <div style="width:12px"></div>

  <div class="pill">Score <span id="score" class="mono">0</span></div>
  <div style="width:8px"></div>
  <div class="pill">Multiplier <span id="mult" class="mono">1</span></div>
  <div style="width:8px"></div>
  <div class="pill">Charge <div style="display:inline-block;width:140px;margin-left:8px"><div class="progressOuter"><div id="chargeFill" class="progressInner" style="width:0%"></div></div></div></div>
  <div style="width:8px"></div>
  <div class="pill">Last <span id="lastJudge" class="mono">-</span></div>

  <div id="playerOffsetWrap" style="margin-left:12px;display:none;align-items:center" class="row">
    <label class="small">Player Offset (ms)</label>
    <input id="playerOffset" type="number" value="0" class="input" style="width:90px">
  </div>
</div>

<div class="container">
  <aside class="card">
    <div style="font-weight:700">Editor / Tools</div>

    <div class="row"><label class="small">Upload Music</label><input id="musicFile" type="file" accept="audio/*" class="input"></div>
    <div class="row"><label class="small">Import Chart (JSON)</label><input id="chartFile" type="file" accept="application/json" class="input"></div>
    <div class="row"><button id="exportBtn" class="btn">Export JSON</button><button id="resetBtn" class="btn">Reset</button></div>

    <hr style="border-color:rgba(255,255,255,0.03)">

    <div class="small">Lanes</div>
    <div class="row"><label class="small">Count</label><input id="laneCount" type="number" min="1" max="12" value="4" class="input" style="width:80px"></div>

    <div id="laneConfig"></div>

    <div class="row"><label class="small">Add notes to lane</label>
      <select id="currentAddLane" class="input"></select>
    </div>

    <hr style="border-color:rgba(255,255,255,0.03)">

    <div class="small">Beat grid settings (for '*' time input)</div>
    <div class="row"><label class="small">First beat offset (ms)</label><input id="firstBeatMs" class="input" type="number" value="0" style="width:120px"></div>
    <div class="row"><label class="small">Beats per minute (BPM)</label><input id="bpm" class="input" type="number" value="120" style="width:120px"></div>

    <hr style="border-color:rgba(255,255,255,0.03)">

    <div class="small">Add Note (at current time)</div>
    <div class="row" id="addBtns">
      <button id="addTap" class="btn">Tap</button>
      <button id="addHold" class="btn">Hold</button>
      <button id="addChord" class="btn">Chord</button>
      <button id="addSeq" class="btn">Seq</button>
      <button id="addFunc" class="btn">Func</button>
    </div>

    <hr style="border-color:rgba(255,255,255,0.03)">

    <div class="small">Judgement windows (ms)</div>
    <div class="row"><label class="small">wonderful</label><input id="j_w" type="number" value="20" class="input" style="width:80px"></div>
    <div class="row"><label class="small">perfect+</label><input id="j_pp" type="number" value="40" class="input" style="width:80px"></div>
    <div class="row"><label class="small">perfect</label><input id="j_p" type="number" value="80" class="input" style="width:80px"></div>
    <div class="row"><label class="small">good</label><input id="j_g" type="number" value="140" class="input" style="width:80px"></div>
    <div class="row"><label class="small">miss late</label><input id="j_late" type="number" value="400" class="input" style="width:80px"></div>

    <hr style="border-color:rgba(255,255,255,0.03)">

    <div class="small">Inspector</div>
    <div class="inspector" id="inspector">
      <div id="noIns" class="small">Click note in canvas or timeline to edit</div>
      <div id="insForm" class="hidden">
        <div class="row"><label class="small">Type</label><select id="ins_type" class="input"><option>tap</option><option>hold</option><option>chord</option><option>seq</option><option>func</option></select></div>
        <div class="row"><label class="small">Lane</label><input id="ins_lane" type="number" class="input" style="width:80px"></div>
        <div class="row"><label class="small">Time (ms or *beat)</label><input id="ins_time" type="text" class="input" style="width:180px"></div>
        <div class="row" id="durRow"><label class="small">Duration ms</label><input id="ins_d" type="number" class="input" style="width:140px"></div>
        <div class="row"><label class="small">Keys (A+B)</label><input id="ins_keys" class="input" placeholder="A+B"></div>
        <div class="row"><label class="small">Thickness</label><input id="ins_thick" type="number" class="input" value="18" style="width:80px"></div>
        <div class="row"><label class="small">Color</label><input id="ins_color" type="color" class="input" value="#6ad1ff"></div>
        <div class="row"><label class="small">Func JSON (keys & speed)</label><input id="ins_func" class="input" placeholder='{"keys":["A"],"speed":800}'></div>
        <div class="row"><button id="ins_apply" class="btn">Apply</button><button id="ins_del" class="btn">Delete</button></div>
      </div>
    </div>

  </aside>

  <main class="card">
    <div style="display:flex;gap:12px;align-items:center;margin-bottom:8px">
      <div class="small">Rate</div>
      <input id="rate" type="range" min="0.3" max="3" step="0.1" value="1">
      <div id="rateVal" class="pill mono">1.0x</div>
      <div style="flex:1"></div>
      <div class="pill">Last Result <span id="hudLast" class="mono">-</span></div>
    </div>

    <canvas id="stage" width="1200" height="560"></canvas>

    <div style="height:8px"></div>

    <div class="row">
      <input id="seek" type="range" min="0" max="0" step="0.001" style="flex:1">
      <div class="pill mono" id="timeDisplay">00:00.000</div>
      <button id="start" class="btn primary">Start</button>
      <button id="pause" class="btn">Pause</button>
      <button id="stop" class="btn">Stop</button>
    </div>

    <div id="timeline" class="timeline"></div>
  </main>
</div>

<audio id="audio" crossorigin="anonymous"></audio>

<script>
/* RhythmStudio — Full single-file
   - preserves editor & play features
   - keeps lane-moving fix
   - shows keys on notes
   - default note thickness 18
   - star-time (*beat) is stored as string and evaluated dynamically
   - Play mode player offset (ms) affects judgement only
   - Backspace/Delete not bound to delete
*/

// Basic setup
const audio = document.getElementById('audio');
const canvas = document.getElementById('stage');
const ctx = canvas.getContext('2d');
const DPR = window.devicePixelRatio || 1;
function resize(){ const r = canvas.getBoundingClientRect(); canvas.width = Math.floor(r.width * DPR); canvas.height = Math.floor(r.height * DPR); ctx.setTransform(DPR,0,0,DPR,0,0); }
window.addEventListener('resize', resize);
resize();

// DOM
const musicFile = document.getElementById('musicFile'), chartFile = document.getElementById('chartFile'), exportBtn = document.getElementById('exportBtn'), resetBtn = document.getElementById('resetBtn');
const laneCount = document.getElementById('laneCount'), laneConfig = document.getElementById('laneConfig'), currentAddLane = document.getElementById('currentAddLane');
const firstBeatMsInp = document.getElementById('firstBeatMs'), bpmInp = document.getElementById('bpm');
const addTap = document.getElementById('addTap'), addHold = document.getElementById('addHold'), addChord = document.getElementById('addChord'), addSeq = document.getElementById('addSeq'), addFunc = document.getElementById('addFunc');
const j_w = document.getElementById('j_w'), j_pp = document.getElementById('j_pp'), j_p = document.getElementById('j_p'), j_g = document.getElementById('j_g'), j_late = document.getElementById('j_late');
const insForm = document.getElementById('insForm'), noIns = document.getElementById('noIns');
const ins_type = document.getElementById('ins_type'), ins_lane = document.getElementById('ins_lane'), ins_time = document.getElementById('ins_time'), ins_d = document.getElementById('ins_d'), ins_keys = document.getElementById('ins_keys'), ins_thick = document.getElementById('ins_thick'), ins_color = document.getElementById('ins_color'), ins_func = document.getElementById('ins_func'), ins_apply = document.getElementById('ins_apply'), ins_del = document.getElementById('ins_del');
const rate = document.getElementById('rate'), rateVal = document.getElementById('rateVal');
const seek = document.getElementById('seek'), timeDisplay = document.getElementById('timeDisplay'), startBtn = document.getElementById('start'), pauseBtn = document.getElementById('pause'), stopBtn = document.getElementById('stop'), timeline = document.getElementById('timeline');
const scoreEl = document.getElementById('score'), multEl = document.getElementById('mult'), chargeFill = document.getElementById('chargeFill'), lastJudge = document.getElementById('lastJudge'), hudLast = document.getElementById('hudLast');
const modeSelect = document.getElementById('modeSelect');
const playerOffsetWrap = document.getElementById('playerOffsetWrap'), playerOffsetInput = document.getElementById('playerOffset');

// model
let chart = { meta:{ defaultKeys:['A','B'], defaultSpeed:600 }, lanes:[] };
let nextId = 1;

// runtime
let pressed = new Set();
let lastPress = {};
let score = 0, multiplier = 1, charge = 0;
const CHARGE_THRESHOLD = 30;
let mode = 'editor';
let playbackRate = 1;
let selectedNote = null;
let playerOffsetMs = 0;

// ensure initial lanes
function ensureLanes(n){
  while(chart.lanes.length < n) chart.lanes.push({ baseKeys: chart.meta.defaultKeys.slice(), baseSpeed: chart.meta.defaultSpeed || 600, notes: [] });
  while(chart.lanes.length > n) chart.lanes.pop();
  rebuildLaneUI();
  rebuildCurrentLaneOptions();
}
ensureLanes(+laneCount.value);

// UI: lanes
function rebuildLaneUI(){
  laneConfig.innerHTML = '';
  chart.lanes.forEach((lane,i)=>{
    const row = document.createElement('div'); row.className='row';
    const lab = document.createElement('div'); lab.className='small'; lab.textContent = `Lane ${i+1}`;
    const keys = document.createElement('input'); keys.className='input'; keys.value = (lane.baseKeys||[]).join('+');
    keys.addEventListener('change', ()=> lane.baseKeys = keys.value.split(/\s*\+\s*/).map(s=>s.trim().toUpperCase()).filter(Boolean));
    const speed = document.createElement('input'); speed.className='input'; speed.type='number'; speed.value = lane.baseSpeed || chart.meta.defaultSpeed; speed.style.width='96px';
    speed.addEventListener('change', ()=> lane.baseSpeed = +speed.value || chart.meta.defaultSpeed);
    row.appendChild(lab); row.appendChild(keys); row.appendChild(speed);
    laneConfig.appendChild(row);
  });
}
function rebuildCurrentLaneOptions(){
  currentAddLane.innerHTML = '';
  for(let i=0;i<chart.lanes.length;i++){
    const opt = document.createElement('option'); opt.value = i; opt.textContent = `Lane ${i+1}`; currentAddLane.appendChild(opt);
  }
}

// parseTime: supports '*' beat-syntax dynamic evaluation
function parseTimeToSec(raw){
  if(raw === undefined || raw === null) return 0;
  if(typeof raw === 'number') return raw;
  raw = String(raw).trim();
  if(raw.startsWith('*')){
    // syntax: *beat|denominator|...
    const rawBody = raw.slice(1);
    const parts = rawBody.split('|').map(s=>s.trim()).filter(Boolean);
    const beatIndex = parseFloat(parts[0]) || 0;
    let frac = 0;
    for(let i=1;i<parts.length;i++){
      const den = parseFloat(parts[i]) || 0;
      if(den>0) frac += 1/den;
    }
    const firstMs = +(firstBeatMsInp.value||0);
    const bpm = +(bpmInp.value||120);
    const beatMs = 60000 / Math.max(1, bpm);
    const totalMs = firstMs + ((beatIndex - 1) + frac) * beatMs;
    return totalMs / 1000.0;
  }
  // support mm:ss.xxx, seconds, or ms (if >1000 assume ms)
  if(/^\d+:\d+\.\d+$/.test(raw)){ const [mm,rest]=raw.split(':'); return parseInt(mm)*60 + parseFloat(rest); }
  if(/^\d+:\d+$/.test(raw)){ const [mm,ss]=raw.split(':'); return parseInt(mm)*60 + parseInt(ss); }
  if(/^\d+$/.test(raw) && raw.length>4) return parseInt(raw)/1000.0;
  const v = parseFloat(raw);
  return isFinite(v) ? v : 0;
}

// add notes (default thickness = 18)
function addNote(type){
  ensureLanes(+laneCount.value);
  const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, +currentAddLane.value || 0));
  const t = audio.currentTime || 0;
  const n = { id: nextId++, type, lane: laneIdx, t: t, d: (type==='hold'?1000:0), keys: null, thickness: 18, color:'#6ad1ff', func:null, _judged:false, _holding:false, holdProgress:0, origTimeInput: null };
  chart.lanes[laneIdx].notes.push(n);
  chart.lanes[laneIdx].notes.sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
  resetRuntimeFrom(parseTimeToSec(t) - 0.05);
  rebuildTimeline(); draw();
}
addTap.addEventListener('click', ()=> addNote('tap'));
addHold.addEventListener('click', ()=> addNote('hold'));
addChord.addEventListener('click', ()=> addNote('chord'));
addSeq.addEventListener('click', ()=> addNote('seq'));
addFunc.addEventListener('click', ()=> {
  const laneIdx = Math.max(0, Math.min(chart.lanes.length-1, +currentAddLane.value || 0));
  const t = audio.currentTime || 0;
  const func = { keys: chart.lanes[laneIdx].baseKeys.slice(), speed: chart.lanes[laneIdx].baseSpeed || chart.meta.defaultSpeed };
  const n = { id: nextId++, type:'func', lane: laneIdx, t: t, d:0, func, color:'#ffd36b' };
  chart.lanes[laneIdx].notes.push(n);
  chart.lanes[laneIdx].notes.sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
  resetRuntimeFrom(parseTimeToSec(t) - 0.05);
  rebuildTimeline(); draw();
});

// import/export
musicFile.addEventListener('change', e=>{
  const f = e.target.files[0]; if(!f) return;
  if(audio.src) try{ URL.revokeObjectURL(audio.src); }catch(e){}
  audio.src = URL.createObjectURL(f); audio.load();
  audio.addEventListener('loadedmetadata', ()=>{ seek.max = Math.max(audio.duration||0, getChartEnd()); rebuildTimeline(); }, { once:true });
});
chartFile.addEventListener('change', async e=>{
  const f = e.target.files[0]; if(!f) return;
  const txt = await f.text();
  try{
    const obj = JSON.parse(txt);
    chart = obj;
    chart.lanes.forEach(l=> l.notes = l.notes || []);
    nextId = Math.max(nextId, ...chart.lanes.flatMap(l=>l.notes.map(n=>n.id||0))) + 1;
    ensureLanes(chart.lanes.length);
    resetRuntimeAll();
    rebuildTimeline(); draw();
  }catch(err){ alert('Invalid JSON'); }
});
exportBtn.addEventListener('click', ()=>{ const blob = new Blob([JSON.stringify(chart,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href = URL.createObjectURL(blob); a.download='chart.json'; a.click(); });
resetBtn.addEventListener('click', ()=>{ audio.pause(); audio.currentTime = 0; resetRuntimeAll(); rebuildTimeline(); draw(); });

// timeline rendering
function getChartEnd(){ let m=0; for(const l of chart.lanes) for(const n of l.notes) m = Math.max(m, parseTimeToSec(n.t) + (n.d? n.d/1000:0)); return m; }
function rebuildTimeline(){
  timeline.innerHTML = '';
  const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0)));
  for(let s=0;s<dur;s++){
    const v = document.createElement('div'); v.style.position='absolute'; v.style.left=(s/dur*100)+'%'; v.style.top='0'; v.style.bottom='0'; v.style.width='1px'; v.style.background=(s%5===0)?'rgba(255,255,255,0.03)':'rgba(255,255,255,0.01)';
    timeline.appendChild(v);
  }
  chart.lanes.forEach((lane,i)=>{
    lane.notes.forEach(n=>{
      const chip = document.createElement('div'); chip.className='note-chip'; chip.dataset.id = n.id;
      chip.style.top = (6 + i*22) + 'px';
      chip.style.left = ((parseTimeToSec(n.t)/dur*100) + '%');
      chip.textContent = n.type === 'tap' ? (n.keys? n.keys.join('+') : 'Tap') : (n.type==='hold'?'Hold':n.type.toUpperCase());
      chip.style.background = n.color || '#6ad1ff';
      chip.addEventListener('click', ev=>{ ev.stopPropagation(); selectNoteById(n.id); showInspector(n); });
      timeline.appendChild(chip);
    });
  });
  const cur = document.createElement('div'); cur.id='miniCursor'; cur.style.position='absolute'; cur.style.top='0'; cur.style.bottom='0'; cur.style.width='2px'; cur.style.background='#6ad1ff';
  timeline.appendChild(cur);
  updateMiniCursor();
}
function updateMiniCursor(){ const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0))); const cur = document.getElementById('miniCursor'); if(cur) cur.style.left = ((audio.currentTime||0)/dur*100) + '%'; }

// inspector (select/move-lane fix)
function selectNoteById(id){
  for(const lane of chart.lanes){
    const n = lane.notes.find(x=>x.id===id);
    if(n){ selectedNote = n; showInspector(n); return; }
  }
  selectedNote = null; hideInspector();
}
function showInspector(n){
  noIns.style.display='none'; insForm.classList.remove('hidden');
  ins_type.value = n.type; ins_lane.value = n.lane || 0; ins_time.value = (typeof n.t === 'string' ? n.t : Math.round(parseTimeToSec(n.t)*1000)); ins_d.value = n.d || 0;
  ins_keys.value = n.keys ? n.keys.join('+') : ''; ins_thick.value = n.thickness || 18; ins_color.value = n.color || '#6ad1ff'; ins_func.value = n.func ? JSON.stringify(n.func) : '';
}
function hideInspector(){ noIns.style.display='block'; insForm.classList.add('hidden'); selectedNote = null; }

// apply inspector edits, move note if lane changed
ins_apply.addEventListener('click', ()=>{
  if(!selectedNote) return;
  const oldLane = selectedNote.lane || 0;
  const newLane = Math.max(0, Math.min(chart.lanes.length-1, +ins_lane.value || 0));
  // time input accept '*' or ms or seconds; we store raw input as string if contains '*'
  const timeInputStr = (ins_time.value || '').toString().trim();
  selectedNote.t = timeInputStr.startsWith('*') ? timeInputStr : (timeInputStr === '' ? selectedNote.t : (parseFloat(timeInputStr) > 10000 ? (parseFloat(timeInputStr)/1000) : parseFloat(timeInputStr)));
  selectedNote.d = (+ins_d.value || 0);
  const kstr = ins_keys.value.trim(); selectedNote.keys = kstr ? kstr.split(/\s*\+\s*/).map(s=>s.toUpperCase()) : null;
  selectedNote.thickness = +ins_thick.value || 18;
  selectedNote.color = ins_color.value || '#6ad1ff';
  try{ selectedNote.func = selectedNote.type==='func' && ins_func.value.trim() ? JSON.parse(ins_func.value) : null; } catch(e){ alert('Invalid func JSON'); return; }
  if(newLane !== oldLane){
    const idx = (chart.lanes[oldLane].notes || []).findIndex(n => n.id === selectedNote.id);
    if(idx !== -1) chart.lanes[oldLane].notes.splice(idx, 1);
    selectedNote.lane = newLane;
    chart.lanes[newLane].notes.push(selectedNote);
    chart.lanes[newLane].notes.sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
  } else {
    chart.lanes[oldLane].notes.sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
  }
  resetRuntimeFrom(parseTimeToSec(selectedNote.t) - 0.05);
  rebuildTimeline(); draw();
});

// delete via inspector only
ins_del.addEventListener('click', ()=>{
  if(!selectedNote) return;
  if(!confirm('Delete note?')) return;
  for(const lane of chart.lanes){
    const i = lane.notes.findIndex(n => n.id === selectedNote.id);
    if(i >= 0){ lane.notes.splice(i,1); selectedNote = null; hideInspector(); rebuildTimeline(); draw(); return; }
  }
});

// playback controls & UI
rate.addEventListener('input', ()=>{ playbackRate = +rate.value; rateVal.textContent = playbackRate.toFixed(1) + 'x'; audio.playbackRate = playbackRate; });
startBtn.addEventListener('click', async ()=>{
  if(!audio.src){ alert('Please upload music'); return; }
  mode = modeSelect.value;
  if(mode === 'play'){ document.getElementById('addBtns').style.display='none'; laneConfig.style.display='none'; seek.disabled = true; playerOffsetWrap.style.display = 'flex'; }
  else { document.getElementById('addBtns').style.display='flex'; laneConfig.style.display='block'; seek.disabled = false; playerOffsetWrap.style.display = 'none'; }
  audio.playbackRate = playbackRate;
  try{ await audio.play(); }catch(e){ console.warn(e); }
});
pauseBtn.addEventListener('click', ()=>{ if(mode==='play' && !audio.paused){ alert('Cannot pause in Play mode'); return; } if(audio.paused) audio.play(); else audio.pause(); });
stopBtn.addEventListener('click', ()=>{ audio.pause(); audio.currentTime = 0; resetRuntimeAll(); rebuildTimeline(); draw(); });
seek.addEventListener('input', ()=>{ seek._dragging = true; });
seek.addEventListener('change', ()=>{ seek._dragging = false; const t = +seek.value || 0; audio.currentTime = t; resetRuntimeFrom(t - 0.05); updateMiniCursor(); draw(); });

// left/right hold to move timeline continuously
let leftInterval=null, rightInterval=null;
document.addEventListener('keydown', e=>{
  if(e.repeat) return;
  if(e.key === 'ArrowLeft' && !leftInterval){
    const step = e.shiftKey ? 0.01 : 0.05;
    const fn = ()=>{ if(mode==='play' && !audio.paused) return; const nt = Math.max(0, (audio.currentTime||0) - step); audio.currentTime = nt; resetRuntimeFrom(nt - 0.05); updateMiniCursor(); draw(); };
    fn(); leftInterval = setInterval(fn, 60); return;
  }
  if(e.key === 'ArrowRight' && !rightInterval){
    const step = e.shiftKey ? 0.01 : 0.05;
    const fn = ()=>{ if(mode==='play' && !audio.paused) return; const nt = Math.min((audio.duration||0), (audio.currentTime||0) + step); audio.currentTime = nt; resetRuntimeFrom(nt - 0.05); updateMiniCursor(); draw(); };
    fn(); rightInterval = setInterval(fn, 60); return;
  }
  // normal keys recorded for judgement
  if(e.key.length === 1){
    pressed.add(e.key.toUpperCase());
    if(!lastPress[e.key.toUpperCase()]) lastPress[e.key.toUpperCase()] = [];
    lastPress[e.key.toUpperCase()].push(audio.currentTime || 0);
    handleSeqSecondKey(e.key.toUpperCase());
  }
  if(e.key === '/') { if(mode === 'editor') addNote('tap'); e.preventDefault(); }
  // NO Backspace/Delete quick-delete
});
document.addEventListener('keyup', e=>{
  if(e.key === 'ArrowLeft' && leftInterval){ clearInterval(leftInterval); leftInterval=null; }
  if(e.key === 'ArrowRight' && rightInterval){ clearInterval(rightInterval); rightInterval=null; }
  if(e.key.length === 1){ pressed.delete(e.key.toUpperCase()); processHoldReleases(); }
});

// effective lane keys & speed at time t (func applies dynamically)
function effectiveLaneAt(laneIdx, tSec){
  const lane = chart.lanes[laneIdx] || { baseKeys: chart.meta.defaultKeys, baseSpeed: chart.meta.defaultSpeed || 600, notes:[] };
  let keys = lane.baseKeys || chart.meta.defaultKeys.slice();
  let speed = lane.baseSpeed || chart.meta.defaultSpeed || 600;
  // find funcs up to time t
  const funcs = (lane.notes||[]).filter(n=> n.type==='func' && parseTimeToSec(n.t) <= tSec).sort((a,b)=>parseTimeToSec(a.t)-parseTimeToSec(b.t));
  if(funcs.length>0){
    const last = funcs[funcs.length-1];
    if(last.func){
      if(Array.isArray(last.func.keys) && last.func.keys.length) keys = last.func.keys.slice();
      if(typeof last.func.speed === 'number') speed = last.func.speed;
    }
  }
  return { keys, speed };
}

// runtime reset helpers
function resetRuntimeAll(){
  for(const lane of chart.lanes) for(const n of lane.notes){
    delete n._judged; delete n._holding; delete n._holdStart; delete n._seqFirst; delete n._seqAwait; delete n._seqSecondGot; delete n._hitFade; delete n._autoFadeTimer;
    n.holdProgress = 0;
  }
  score = 0; multiplier = 1; charge = 0; updateHUD();
}
function resetRuntimeFrom(tSec){
  for(const lane of chart.lanes) for(const n of lane.notes) if(parseTimeToSec(n.t) >= tSec){
    delete n._judged; delete n._holding; delete n._holdStart; delete n._seqFirst; delete n._seqAwait; delete n._seqSecondGot; delete n._hitFade; delete n._autoFadeTimer;
    n.holdProgress = 0;
  }
}

// scoring & judge
const BASE = { wonderful:60, 'perfect+':50, perfect:40, good:20 };
function judgeLabel(ms){
  const J = { w:+j_w.value, pp:+j_pp.value, p:+j_p.value, g:+j_g.value };
  const a = Math.abs(ms);
  if(a <= J.w) return 'wonderful';
  if(a <= J.pp) return 'perfect+';
  if(a <= J.p) return 'perfect';
  if(a <= J.g) return 'good';
  return null;
}
function applyJudge(label){
  if(!label){ multiplier = 1; charge = 0; updateHUD(); lastJudge.textContent='miss'; hudLast.textContent='miss'; return; }
  hudLast.textContent = label;
  const base = BASE[label] || 0;
  score += Math.floor(base * multiplier);
  let add = 0;
  if(label === 'perfect') add = 1;
  if(label === 'perfect+') add = 2;
  if(label === 'wonderful') add = 4;
  if(label === 'good'){ charge = Math.max(0, charge - 5); if(charge < (multiplier-1)*CHARGE_THRESHOLD) multiplier = Math.max(1, Math.floor(charge/CHARGE_THRESHOLD)+1); updateHUD(); return; }
  charge = Math.min(9999, charge + add);
  const newMult = Math.min(4, Math.floor(charge/CHARGE_THRESHOLD)+1);
  multiplier = Math.max(multiplier, newMult);
  updateHUD();
}

// main judge loop uses playerOffsetMs only when mode==='play'
function tickJudge(){
  const now = audio.currentTime || 0;
  const J = { w:+j_w.value, pp:+j_pp.value, p:+j_p.value, g:+j_g.value, late:+j_late.value };
  for(const lane of chart.lanes){
    for(const note of lane.notes){
      if(note.type === 'func') continue;
      const laneIdx = note.lane;
      if(note._judged && !note._holding) continue;
      const tSec = parseTimeToSec(note.t);
      const eff = effectiveLaneAt(laneIdx, tSec);
      // compute delta with optional player offset (only applied in play mode)
      const deltaMs = (now - tSec) * 1000 - (mode==='play' ? playerOffsetMs : 0);
      if(deltaMs > J.g + 1000 && !note._judged){ note._judged = true; note._hitFade = true; applyJudge(null); continue; }
      const lbl = judgeLabel(deltaMs);
      if(note.type === 'tap'){
        if(!note._judged && lbl){
          const need = note.keys && note.keys.length ? note.keys : eff.keys;
          const ok = need && need.length ? need.some(k=> pressed.has(k)) : false;
          if(ok){ note._judged = true; note._hitFade = true; applyJudge(lbl); scheduleFadeClear(note); }
        }
      } else if(note.type === 'chord'){
        const need = note.keys && note.keys.length ? note.keys : eff.keys;
        if(need.length <= 1){
          if(!note._judged && Math.abs(deltaMs) <= J.g){
            const k = need[0];
            if(k && pressed.has(k)){ note._judged = true; note._hitFade = true; const base = Math.round((BASE['perfect']||40)*0.5*multiplier); score += base; updateHUD(); scheduleFadeClear(note); }
          }
        } else {
          if(!note._judged && lbl){
            const all = need.every(k=> pressed.has(k));
            if(all){ note._judged = true; note._hitFade = true; applyJudge(lbl); scheduleFadeClear(note); }
            else if(need.some(k=> pressed.has(k))){ note._judged = true; note._hitFade = true; applyJudge('good'); scheduleFadeClear(note); }
          }
        }
      } else if(note.type === 'seq'){
        const req = note.keys && note.keys.length ? note.keys : eff.keys;
        const first = req[0], second = req[1] || req[0];
        if(!note._seqFirst){
          if(!note._judged && lbl && first && pressed.has(first)){
            note._seqFirst = true; note._seqFirstTime = now; note._judged = true; note._hitFade = true; applyJudge(lbl); note._seqAwait = true; scheduleFadeClear(note);
          }
        } else if(note._seqAwait){
          if(now - (note._seqFirstTime||0) <= ((note.d||1000)/1000)){
            if(second && pressed.has(second) && !note._seqSecondGot){ const base = BASE['perfect'] || 40; const extra = Math.round(base*0.2*multiplier); score += extra; note._seqSecondGot = true; updateHUD(); }
          } else { note._seqAwait = false; multiplier = 1; charge = 0; updateHUD(); }
        }
      } else if(note.type === 'hold'){
        const need = note.keys && note.keys.length ? note.keys : eff.keys;
        const needAll = need && need.length>1;
        const isPressedNow = need && need.length ? (needAll ? need.every(k=>pressed.has(k)) : pressed.has(need[0])) : false;
        if(!note._judged && lbl && isPressedNow){
          // judge at press time only
          note._judged = true; note._holding = true; note._holdStart = Math.max(now, tSec);
          note._initialLabel = lbl;
          const base = BASE[lbl] || 20;
          score += Math.floor(base * multiplier);
          applyJudge(lbl);
          updateHUD();
          // do not fade yet
        }
        if(note._holding){
          const holdStart = note._holdStart || tSec;
          const held = Math.max(0, Math.min(now, tSec + ((note.d||1000)/1000)) - holdStart);
          note.holdProgress = Math.max(0, Math.min(1, held / ((note.d||1000)/1000)));
          if(now >= tSec + ((note.d||1000)/1000) && !note._autoFinal){
            note._autoFinal = true;
            const baseLabel = note._initialLabel || 'good';
            const baseVal = BASE[baseLabel] || 20;
            // hold total extra: base * 0.5 * completionRatio * multiplier
            const totalHoldBonus = Math.round(baseVal * 0.5 * note.holdProgress * multiplier);
            const extra = Math.max(0, totalHoldBonus - 0);
            if(extra>0){ score += extra; updateHUD(); }
            scheduleAutoFade(note); note._holding = false;
          }
        }
      }
    }
  }
}

// handle hold releases
function processHoldReleases(){
  const now = audio.currentTime || 0;
  for(const lane of chart.lanes){
    for(const note of lane.notes){
      if(note.type === 'hold' && note._holding){
        const tSec = parseTimeToSec(note.t);
        const eff = effectiveLaneAt(note.lane, tSec);
        const need = note.keys && note.keys.length ? note.keys : eff.keys;
        const still = need && need.length ? (need.length>1 ? need.every(k=>pressed.has(k)) : pressed.has(need[0])) : false;
        if(!still){
          const holdStart = note._holdStart || tSec;
          const held = Math.max(0, Math.min(now, tSec + ((note.d||1000)/1000)) - holdStart);
          const ratio = Math.min(1, held / ((note.d||1000)/1000));
          note.holdProgress = ratio;
          const baseLabel = note._initialLabel || 'good';
          const baseVal = BASE[baseLabel] || 20;
          const totalHoldBonus = Math.round(baseVal * 0.5 * ratio * multiplier);
          const extra = Math.max(0, totalHoldBonus - 0);
          if(extra>0){ score += extra; updateHUD(); }
          scheduleAutoFade(note);
          note._holding = false;
        }
      }
    }
  }
}

// seq helper when user presses second key
function handleSeqSecondKey(k){
  const now = audio.currentTime || 0;
  for(const lane of chart.lanes){
    for(const note of lane.notes){
      if(note.type === 'seq' && note._seqAwait && !note._seqSecondGot){
        if(now - (note._seqFirstTime||0) <= ((note.d||1000)/1000)){
          const k2 = note.keys && note.keys[1] ? note.keys[1] : null;
          if(k2 && k === k2){
            const base = BASE['perfect'] || 40;
            const extra = Math.round(base * 0.2 * multiplier);
            score += extra; note._seqSecondGot = true; updateHUD();
          }
        }
      }
    }
  }
}

// fade helpers
function scheduleAutoFade(note){ if(note._autoFadeTimer) clearTimeout(note._autoFadeTimer); note._autoFadeTimer = setTimeout(()=>{ note._hitFade = true; }, 450); }
function scheduleFadeClear(note){ if(note._autoFadeTimer) clearTimeout(note._autoFadeTimer); note._autoFadeTimer = setTimeout(()=>{ note._hitFade = true; }, 250); }

// drawing: judge line moved upward (~75%), notes display keys; thickness default 18
function computeLayout(){ const r = canvas.getBoundingClientRect(); return { x:12, y:12, w:r.width-24, h:r.height-24, judgeY: r.height*0.75 }; }
function draw(){
  const L = computeLayout();
  ctx.clearRect(0,0,canvas.clientWidth,canvas.clientHeight);
  ctx.fillStyle = '#021726'; ctx.fillRect(L.x, L.y, L.w, L.h);
  const lanes = Math.max(1, chart.lanes.length);
  const laneW = (L.w - (lanes+1)*12) / lanes;
  const now = audio.currentTime || 0;
  // header for each lane
  for(let i=0;i<lanes;i++){
    const x = L.x + 12 + i*(laneW+12);
    ctx.fillStyle = '#071a2b'; ctx.fillRect(x, L.y+8, laneW, L.h-16);
    const effNow = effectiveLaneAt(i, now);
    ctx.fillStyle = '#9fb8d9'; ctx.font='12px sans-serif';
    ctx.fillText(`L${i+1}: ${(effNow.keys||[]).join('+')} @${Math.round(effNow.speed||0)}`, x+8, L.y+26);
    // draw judge ground line
    ctx.strokeStyle = '#234'; ctx.beginPath(); ctx.moveTo(x, L.y + L.h*0.9); ctx.lineTo(x + laneW, L.y + L.h*0.9); ctx.stroke();
    // draw notes in this lane
    const laneNotes = chart.lanes[i].notes || [];
    for(const n of laneNotes){
      const tSec = parseTimeToSec(n.t);
      const effForNote = effectiveLaneAt(n.lane, tSec);
      const speed = effForNote.speed || chart.lanes[n.lane].baseSpeed || chart.meta.defaultSpeed || 600; // speed in px/sec (we assume)
      // we map speed -> pixels per second; allow speed scale: pxPerSec = speed (default 600) -> 300 px per sec mapping
      const pxPerSec = speed * 0.5; // tweak factor for visible distance
      const dt = tSec - now;
      const baseY = L.y + L.h*0.9;
      const y = baseY - dt * pxPerSec;
      if(y < L.y - 300 || y > L.y + L.h + 300) continue;
      const w = laneW*0.8; const left = x + (laneW - w)/2;
      ctx.globalAlpha = n._hitFade ? 0.45 : 1.0;
      if(n.type === 'func'){
        ctx.fillStyle = n.color || '#ffd36b'; ctx.fillRect(left, y-8, w, 16); ctx.fillStyle='#031029'; ctx.fillText('FUNC', left+8, y+4);
      } else if(n.type === 'tap' || n.type === 'seq'){
        ctx.fillStyle = n.color || '#6ad1ff';
        ctx.fillRect(left, y - (n.thickness||18)/2, w, n.thickness||18);
        ctx.fillStyle = '#001';
        ctx.font = '12px sans-serif';
        const displayKeys = n.keys && n.keys.length ? n.keys.join('+') : (effForNote.keys||[]).join('+');
        if(displayKeys) ctx.fillText(displayKeys, left+8, y+4);
      } else if(n.type === 'chord'){
        ctx.fillStyle = n.color || '#ffb265';
        ctx.fillRect(left, y - (n.thickness||18)/2, w, n.thickness||18);
        ctx.fillStyle='#001'; ctx.font='12px sans-serif';
        const displayKeys = n.keys && n.keys.length ? n.keys.join('+') : (effForNote.keys||[]).join('+');
        if(displayKeys) ctx.fillText(displayKeys, left+8, y+4);
      } else if(n.type === 'hold'){
        const yStart = y;
        const holdLenSec = (n.d || 1000)/1000.0;
        const yEnd = baseY - ((tSec + holdLenSec) - now) * pxPerSec;
        const top = Math.min(yStart, yEnd), bottom = Math.max(yStart, yEnd);
        const visH = Math.max(8, bottom - top);
        let alpha = 1.0;
        if(n._holding) alpha = 0.95;
        else if(n._hitFade) alpha = 0.6;
        ctx.globalAlpha = alpha;
        ctx.fillStyle = n.color || '#b38cff';
        ctx.fillRect(left, top, w, visH);
        if(n.holdProgress){
          ctx.fillStyle = 'rgba(255,255,255,0.12)';
          ctx.fillRect(left, top, w, visH * n.holdProgress);
        }
        const displayKeys = n.keys && n.keys.length ? n.keys.join('+') : ((effForNote.keys||[]).join('+'));
        if(displayKeys){
          ctx.fillStyle = '#001';
          ctx.font = '12px sans-serif';
          ctx.fillText(displayKeys, left+8, top+14);
        }
      }
      ctx.globalAlpha = 1.0;
    }
  }
}

// HUD update
function updateHUD(){ scoreEl.textContent = score; multEl.textContent = multiplier; chargeFill.style.width = Math.min(100, Math.round((charge/CHARGE_THRESHOLD)*100)) + '%'; lastJudge.textContent = hudLast.textContent || '-'; }

// main loop
function loop(){
  tickJudge();
  updateTimeUI();
  draw();
  requestAnimationFrame(loop);
}
requestAnimationFrame(loop);

// time UI updates
function updateTimeUI(){ const ms = Math.floor((audio.currentTime||0)*1000); timeDisplay.textContent = fmtMs(ms); if(!seek._dragging) seek.value = audio.currentTime || 0; updateMiniCursor(); }
function fmtMs(ms){ const s = Math.floor(ms/1000); const mm = Math.floor(s/60), ss = s%60, mss = ms%1000; return `${String(mm).padStart(2,'0')}:${String(ss).padStart(2,'0')}.${String(mss).padStart(3,'0')}`; }
function updateMiniCursor(){ const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0))); const cur = document.getElementById('miniCursor'); if(cur) cur.style.left = ((audio.currentTime||0)/dur*100) + '%'; }

// timeline & canvas click
rebuildTimeline();
function rebuildTimeline(){
  timeline.innerHTML = '';
  const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0)));
  for(let s=0;s<dur;s++){
    const v = document.createElement('div'); v.style.position='absolute'; v.style.left=(s/dur*100)+'%'; v.style.top='0'; v.style.bottom='0'; v.style.width='1px'; v.style.background=(s%5===0)?'rgba(255,255,255,0.03)':'rgba(255,255,255,0.01)';
    timeline.appendChild(v);
  }
  chart.lanes.forEach((lane,i)=>{
    lane.notes.forEach(n=>{
      const chip = document.createElement('div'); chip.className='note-chip'; chip.dataset.id = n.id;
      chip.style.top = (6 + i*22) + 'px';
      chip.style.left = ((parseTimeToSec(n.t)/dur*100) + '%');
      chip.textContent = n.type === 'tap' ? (n.keys? n.keys.join('+') : 'Tap') : (n.type==='hold'?'Hold':n.type.toUpperCase());
      chip.style.background = n.color || '#6ad1ff';
      chip.addEventListener('click', ev=>{ ev.stopPropagation(); selectNoteById(n.id); showInspector(n); });
      timeline.appendChild(chip);
    });
  });
  const cur = document.createElement('div'); cur.id='miniCursor'; cur.style.position='absolute'; cur.style.top='0'; cur.style.bottom='0'; cur.style.width='2px'; cur.style.background='#6ad1ff';
  timeline.appendChild(cur);
  updateMiniCursor();
}
canvas.addEventListener('click', e=>{
  const r = canvas.getBoundingClientRect(); const cx = e.clientX - r.left, cy = e.clientY - r.top;
  const L = computeLayout(); const lanes = Math.max(1, chart.lanes.length); const laneW = (L.w - (lanes+1)*12) / lanes; const now = audio.currentTime || 0;
  for(let i=0;i<lanes;i++){
    const x = L.x + 12 + i*(laneW+12);
    for(const n of chart.lanes[i].notes){
      const tSec = parseTimeToSec(n.t);
      const effForNote = effectiveLaneAt(n.lane, tSec);
      const speed = effForNote.speed || chart.lanes[n.lane].baseSpeed || chart.meta.defaultSpeed;
      const pxPerSec = (speed || 600)*0.5;
      const baseY = L.y + L.h*0.9;
      const y = baseY - (tSec - now)*pxPerSec;
      const w = laneW*0.8; const left = x + (laneW - w)/2;
      if(n.type === 'hold'){
        const holdLenSec = (n.d || 1000)/1000.0;
        const yEnd = baseY - ((tSec + holdLenSec) - now) * pxPerSec;
        const top = Math.min(y, yEnd), bottom = Math.max(y, yEnd);
        if(cx>=left && cx <= left+w && cy >= top && cy <= bottom){ selectNoteById(n.id); showInspector(n); return; }
      } else {
        const th = n.thickness||18;
        if(cx>=left && cx <= left+w && cy >= y-th/2 && cy <= y+th/2){ selectNoteById(n.id); showInspector(n); return; }
      }
    }
  }
  selectedNote = null; hideInspector();
});

timeline.addEventListener('click', e=>{
  const rect = timeline.getBoundingClientRect();
  const x = e.clientX - rect.left;
  const dur = Math.max(5, Math.ceil(Math.max(getChartEnd(), audio.duration||0)));
  const t = (x / timeline.clientWidth) * dur;
  if(mode === 'play' && !audio.paused){ alert('Cannot seek during Play'); return; }
  audio.currentTime = t;
  resetRuntimeFrom(t - 0.05);
  updateMiniCursor(); draw();
});

// get chart end
function getChartEnd(){ let m=0; chart.lanes.forEach(l=> l.notes.forEach(n=> m = Math.max(m, parseTimeToSec(n.t) + (n.d? n.d/1000:0)))); return m; }

// schedule fade utils (needed earlier)
function scheduleAutoFade(note){ if(note._autoFadeTimer) clearTimeout(note._autoFadeTimer); note._autoFadeTimer = setTimeout(()=>{ note._hitFade = true; }, 450); }
function scheduleFadeClear(note){ if(note._autoFadeTimer) clearTimeout(note._autoFadeTimer); note._autoFadeTimer = setTimeout(()=>{ note._hitFade = true; }, 250); }

// update HUD
function updateHUD(){ scoreEl.textContent = score; multEl.textContent = multiplier; chargeFill.style.width = Math.min(100, Math.round((charge/CHARGE_THRESHOLD)*100)) + '%'; lastJudge.textContent = hudLast.textContent || '-'; }

// keyboard: no backspace delete hookup (delete only in inspector)
document.addEventListener('keydown', e=>{
  if(e.repeat) return;
  if(e.key.length === 1){
    pressed.add(e.key.toUpperCase());
    if(!lastPress[e.key.toUpperCase()]) lastPress[e.key.toUpperCase()] = [];
    lastPress[e.key.toUpperCase()].push(audio.currentTime || 0);
    handleSeqSecondKey(e.key.toUpperCase());
  }
});

// player offset UI visibility
modeSelect.addEventListener('change', e=>{
  mode = e.target.value;
  if(mode === 'play'){ playerOffsetWrap.style.display = 'flex'; document.getElementById('addBtns').style.display='none'; laneConfig.style.display='none'; seek.disabled = true; } 
  else { playerOffsetWrap.style.display = 'none'; document.getElementById('addBtns').style.display='flex'; laneConfig.style.display='block'; seek.disabled = false; }
});
playerOffsetInput.addEventListener('input', e=>{ playerOffsetMs = +(e.target.value||0); });

// init UI reactive wiring
laneCount.addEventListener('change', ()=> ensureLanes(+laneCount.value));
rebuildLaneUI(); rebuildCurrentLaneOptions();

// seek handling and left/right drag already added earlier (keep)

// exposed for debug
window._chart = chart;
window._parseTimeToSec = parseTimeToSec;

console.log('RhythmStudio full loaded.');

</script>
</body>
</html>