fighting-konoha/record.js

442 lines
29 KiB
JavaScript

/* Rekam ulang K.O. dan ekspor tangkapan layar (RENCANA 7.3 dan 7.4). Gaya: record.css. Seluruh logika ada di berkas ini; game.js tidak diubah.
Kait di berkas lain: vs.js (ResultView.view memanggil window.Record?.resultHtml(hasil) di dalam .rs-main, ResultView.mount memanggil window.Record?.mount(root))
dan satu baris <link>/<script> di index.html. Keadaan permainan dibaca dari DOM, bukan dari game.js: body.in-menu, atribut #arena data-mode / data-match-phase /
data-character / data-opponent (diisi game.js tiap frame di updateHud), dialog[open] (jeda dan pengaturan), VS.active dan Story.active, document.hidden. Dibaca tiap
150 ms (poll). Bila atribut itu suatu hari hilang, perekaman diam-diam tidak jalan dan tangkapan layar tetap bisa dipakai.
Rekam ulang (7.3): kanvas salinan kecil (maks 854 px, 640 px di layar sentuh) diisi dari #arena sekitar 30 kali per detik oleh rAF sendiri, lalu captureStream(30) -> MediaRecorder
(webm vp8, cadangan vp9/webm; tanpa audio, 1,5 Mbps, 0,9 Mbps di layar sentuh). Hanya potongan pertama sebuah rekaman WebM yang memuat header dan keyframe, jadi "membuang potongan
lama" membuat file tidak bisa diputar. Karena itu dipakai DUA PEREKAM BERGANTIAN: perekam baru dimulai tiap PERIOD (6 dtk waktu rekam), masing-masing berhenti sesudah 2 x PERIOD
dan isinya dibuang. Klip = perekam tertua (6 sampai 12 dtk), selalu satu berkas WebM utuh, tanpa mengurai atau menyambung kontainer. Memori terbatas: dua perekam x maks 12 dtk
(sekitar 2 MB masing-masing) ditambah satu klip jadi dan satu bingkai terakhir arena. Jeda, Pengaturan, layar VS/kartu cerita dan tab tersembunyi memanggil pause() (jam rekam ikut
berhenti, jadi klip tidak berisi bingkai beku); menu atau pertandingan yang dibatalkan membuang semuanya. Saat matchPhase menjadi 'complete' (atau ResultView.view lebih dulu, karena
layar hasil dipasang pada tick yang sama dengan event 'complete') perekam tertua dihentikan menjadi klip dan bingkai terakhir arena dibekukan untuk tombol FOTO di layar hasil.
Klip dibuang saat pertandingan berikutnya mulai. MediaRecorder atau tipe webm tidak ada, atau ada galat: fitur klip hilang tanpa suara (tangkapan layar tetap jalan).
Tangkapan layar (7.4): kanvas salinan + watermark KONOHA FIGHTING di pojok kanan bawah -> PNG. Tombol kamera di HUD (.match-actions), baris di menu jeda, tombol FOTO dan BAGIKAN di layar
hasil, pintasan F9 (bukan tombol game, tidak bisa diubah-petakan oleh input.js). BAGIKAN memakai navigator.share dengan berkas bila bisa, selain itu mengunduh.
Simpanan: localStorage['konoha.record.v1'] = {on, badge}. Konsol: Record.state(), Record.set(false). Tidak memakai Date.now/Math.random. */
(() => {
'use strict';
const arena = document.getElementById('arena');
if (!arena) return;
const $ = s => document.querySelector(s);
const warn = e => { try { console.warn('Record:', e && e.message ? e.message : e); } catch (_) { /* konsol tidak tersedia */ } };
const store = { get(k) { try { return localStorage.getItem(k); } catch (_) { return null; } }, set(k, v) { try { localStorage.setItem(k, v); } catch (_) { /* penyimpanan diblokir */ } } };
const KEY = 'konoha.record.v1';
const cfg = (() => { let d = {}; try { d = JSON.parse(store.get(KEY) || '{}') || {}; } catch (_) { /* simpanan rusak: pakai bawaan */ } return { on: d.on !== false, badge: d.badge !== false }; })();
const saveCfg = () => store.set(KEY, JSON.stringify({ on: cfg.on, badge: cfg.badge }));
const FPS = 30, PERIOD = 6000, POLL = 150, MIN_MS = 1500, MIN_BYTES = 3000, MAX_SHOT = 1920;
const TYPES = ['video/webm;codecs=vp8', 'video/webm;codecs=vp9', 'video/webm'];
const MIME = (() => {
try {
if (typeof arena.captureStream !== 'function' || typeof MediaRecorder !== 'function' || typeof MediaRecorder.isTypeSupported !== 'function') return '';
return TYPES.find(t => MediaRecorder.isTypeSupported(t)) || '';
} catch (_) { return ''; }
})();
// state: idle (belum merekam) | run | hold (dijeda) | done (pertandingan selesai, klip diproses atau siap). slots = perekam yang berjalan (paling banyak dua), tertua dulu.
const S = { rc: null, rx: null, stream: null, bps: 1500000, slots: [], state: 'idle', fin: false, clip: null, frame: null, raf: 0, last: 0, t: performance.now(), phase: '', menu: true, stale: false, dead: false, badge: null, seq: 0 };
const slug = s => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
function fileName(ext, frozen) {
const ds = arena.dataset, w = frozen && S.clip ? S.clip : null, a = slug(w ? w.player : ds.character) || 'petarung', b = slug(w ? w.enemy : ds.opponent) || 'lawan';
return `konoha-fighting-${a}${w || ds.mode === 'versus' ? '-vs-' + b : ''}.${ext}`;
}
function save(blob, name) {
const url = URL.createObjectURL(blob), a = document.createElement('a');
a.href = url; a.download = name; a.rel = 'noopener'; a.hidden = true;
document.body.append(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 20000);
}
// Umpan balik di atas dialog modal harus berada di dalam dialog itu (lapisan atas), kalau tidak tersembunyi di belakang latar dialog.
function toast(text, bad) {
const open = document.querySelectorAll('dialog[open]'), host = open.length ? open[open.length - 1] : document.body;
const old = document.getElementById('rec-toast'); if (old) old.remove();
const el = document.createElement('div'); el.id = 'rec-toast'; el.className = 'rec-toast' + (bad ? ' bad' : ''); el.setAttribute('role', 'status'); el.textContent = text;
host.append(el); clearTimeout(toast.t); toast.t = setTimeout(() => el.remove(), 2400);
}
function shutter(b) {
try { window.__game?.menuSound?.('blink'); } catch (_) { /* audio belum aktif */ }
if (b && b.id === 'rec-shot') { b.classList.add('rec-done'); setTimeout(() => b.classList.remove('rec-done'), 700); }
if (window.Access?.reduced || document.querySelector('dialog[open]')) return;
const f = document.createElement('div'); f.className = 'rec-flash'; document.body.append(f); setTimeout(() => f.remove(), 340);
}
const refocus = () => { if (!document.querySelector('dialog[open]') && !document.body.classList.contains('in-menu')) arena.focus({ preventScroll: true }); }; // Spasi di tombol HUD akan menekan tombolnya, bukan menyerang
// ---- tangkapan layar ----
function mark(g, w, h) {
const unit = Math.min(w, h * 16 / 9), fs = Math.max(13, Math.round(unit * .019)), pad = Math.round(fs * .7), gap = Math.round(fs * .5), dia = fs * .62, text = 'KONOHA FIGHTING';
g.save();
g.font = `700 ${fs}px Rajdhani,"Arial Narrow",Arial,sans-serif`; g.textBaseline = 'alphabetic';
try { g.letterSpacing = Math.max(1, Math.round(fs * .16)) + 'px'; } catch (_) { /* tanpa jarak huruf */ }
const tw = g.measureText(text).width, bw = pad * 2 + dia + gap + tw, bh = fs + pad * 1.4, x = w - bw - Math.round(fs * .9), y = h - bh - Math.round(fs * .9), cx = x + pad + dia / 2, cy = y + bh / 2;
g.fillStyle = 'rgba(8,19,28,.68)'; g.fillRect(x, y, bw, bh);
g.fillStyle = '#e6c78f'; g.fillRect(x, y, Math.max(2, fs * .12), bh);
g.beginPath(); g.moveTo(cx, cy - dia / 2); g.lineTo(cx + dia / 2, cy); g.lineTo(cx, cy + dia / 2); g.lineTo(cx - dia / 2, cy); g.closePath(); g.fill();
g.fillStyle = '#fff3d4'; g.shadowColor = 'rgba(0,0,0,.6)'; g.shadowBlur = fs * .25; g.fillText(text, x + pad + dia + gap, cy + fs * .34);
g.restore();
}
// frozen = layar hasil: pakai bingkai terakhir pertandingan bila ada (arena di belakang menu sudah berubah), selain itu kanvas arena yang sekarang.
function shotCanvas(frozen) {
const src = frozen && S.frame && S.frame.width ? S.frame : arena, sw = src.width, sh = src.height; if (!sw || !sh) return null;
const k = Math.min(1, MAX_SHOT / sw), w = Math.round(sw * k), h = Math.round(sh * k), c = document.createElement('canvas'); c.width = w; c.height = h;
const g = c.getContext('2d'); g.imageSmoothingQuality = 'high'; g.drawImage(src, 0, 0, sw, sh, 0, 0, w, h); mark(g, w, h);
return c;
}
// Sinkron (tanpa menunggu) supaya navigator.share masih berada di dalam gestur klik; dipakai hanya untuk BAGIKAN.
function blobSync(c) {
const u = c.toDataURL('image/png'), b = atob(u.slice(u.indexOf(',') + 1)), a = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) a[i] = b.charCodeAt(i);
return new Blob([a], { type: 'image/png' });
}
let busy = false;
async function shotSave(b, frozen) {
if (busy) return; busy = true;
try {
const c = shotCanvas(frozen); if (!c) { toast('Arena belum siap', true); return; }
const blob = await new Promise((res, rej) => c.toBlob(x => (x ? res(x) : rej(new Error('PNG kosong'))), 'image/png'));
c.width = c.height = 0;
save(blob, fileName('png', frozen)); shutter(b); toast('Tangkapan layar disimpan');
} catch (e) { warn(e); toast(e && e.name === 'SecurityError' ? 'Tidak bisa menyimpan dari berkas lokal, buka lewat server' : 'Gagal menyimpan tangkapan layar', true); }
finally { busy = false; }
}
function shotShare(b, frozen) {
if (busy) return; busy = true;
const done = () => { busy = false; };
try {
const c = shotCanvas(frozen); if (!c) { toast('Arena belum siap', true); done(); return; }
const blob = blobSync(c), name = fileName('png', frozen); c.width = c.height = 0;
let file = null; try { file = new File([blob], name, { type: 'image/png' }); } catch (_) { /* tanpa konstruktor File */ }
let can = false; try { can = !!(file && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })); } catch (_) { /* canShare tanpa dukungan berkas */ }
if (can) {
navigator.share({ files: [file], title: 'Konoha Fighting', text: 'Tangkapan layar dari Konoha Fighting' })
.then(() => toast('Dibagikan'), e => { if (e && e.name === 'AbortError') return; save(blob, name); toast('Berbagi gagal, gambar diunduh'); })
.then(done, done);
} else { save(blob, name); toast('Berbagi tidak tersedia, gambar diunduh'); done(); }
} catch (e) { warn(e); toast('Gagal membagikan', true); done(); }
}
function grabFrame() {
freeFrame();
try {
const sw = arena.width, sh = arena.height; if (!sw || !sh) return;
const k = Math.min(1, MAX_SHOT / sw), c = document.createElement('canvas'); c.width = Math.round(sw * k); c.height = Math.round(sh * k);
c.getContext('2d').drawImage(arena, 0, 0, sw, sh, 0, 0, c.width, c.height); S.frame = c;
} catch (_) { S.frame = null; }
}
function freeFrame() { if (S.frame) { S.frame.width = S.frame.height = 0; S.frame = null; } }
// ---- perekam bergulir ----
function dropRec() {
try { if (S.stream) S.stream.getTracks().forEach(t => t.stop()); } catch (_) { /* aliran sudah berhenti */ }
if (S.rc) S.rc.width = S.rc.height = 0;
S.rc = S.rx = S.stream = null;
}
function ensureRec() {
const sw = arena.width, sh = arena.height; if (!(sw > 0 && sh > 0)) return false;
const maxW = document.documentElement.classList.contains('touch') ? 640 : 854;
const w = Math.min(maxW, sw) & ~1, h = Math.round(w * sh / sw) & ~1; if (w < 64 || h < 64) return false;
if (S.rc && (Math.abs(S.rc.width - w) > 96 || Math.abs(S.rc.width / S.rc.height - w / h) > .08)) dropRec(); // jendela berubah antar pertandingan: geometri baru
if (!S.rc) {
const c = document.createElement('canvas'); c.width = w; c.height = h;
const x = c.getContext('2d', { alpha: false }); if (!x) return false;
x.imageSmoothingEnabled = true; x.imageSmoothingQuality = 'medium'; x.fillStyle = '#000'; x.fillRect(0, 0, w, h);
S.stream = c.captureStream(); S.rc = c; S.rx = x; // tanpa batas bingkai: pump di bawah sudah membatasi ke ~30/dtk, batas ganda bisa membuang bingkai secara tidak merata
}
return true;
}
// Letterbox bila rasio jendela berubah di tengah pertandingan (ukuran video tetap sepanjang sesi supaya penyandi tidak berganti resolusi).
function paint() {
const w = arena.width, h = arena.height, rc = S.rc; if (!rc || !w || !h) return;
const k = Math.min(rc.width / w, rc.height / h), dw = Math.round(w * k), dh = Math.round(h * k);
if (dw < rc.width - 1 || dh < rc.height - 1) { S.rx.fillStyle = '#000'; S.rx.fillRect(0, 0, rc.width, rc.height); }
S.rx.drawImage(arena, 0, 0, w, h, (rc.width - dw) >> 1, (rc.height - dh) >> 1, dw, dh);
}
function pump(ts) {
S.raf = 0;
if (S.state !== 'run') return;
S.raf = requestAnimationFrame(pump);
if (ts - S.last < 1000 / FPS - 4) return; // layar 60 Hz: tiap bingkai kedua; layar lebih cepat: sedikit di bawah 30
S.last = ts;
try { paint(); } catch (e) { fail(e); }
}
const startPump = () => { if (!S.raf) { S.last = 0; S.raf = requestAnimationFrame(pump); } };
const stopPump = () => { if (S.raf) { cancelAnimationFrame(S.raf); S.raf = 0; } };
function addSlot() {
const mr = new MediaRecorder(S.stream, { mimeType: MIME, videoBitsPerSecond: S.bps }), slot = { mr, chunks: [], age: 0 };
mr.ondataavailable = e => { if (e.data && e.data.size) slot.chunks.push(e.data); };
mr.onerror = () => { if (S.slots.includes(slot)) fail(new Error('MediaRecorder')); };
mr.start(1000);
S.slots.push(slot);
}
function killSlot(s) {
const mr = s.mr; s.chunks = [];
mr.ondataavailable = mr.onerror = mr.onstop = null;
try { if (mr.state !== 'inactive') mr.stop(); } catch (_) { /* sudah berhenti */ }
}
const discardSlots = () => { for (const s of S.slots.splice(0)) killSlot(s); };
function start() {
try {
if (!ensureRec()) return false;
S.bps = document.documentElement.classList.contains('touch') ? 900000 : 1500000;
paint(); addSlot(); return true;
} catch (e) { fail(e); return false; }
}
function run(dt) {
if (S.state === 'hold') resumeAll();
if (!S.slots.length) { if (!start()) return; dt = 0; }
S.state = 'run'; startPump(); badge();
for (const s of S.slots) s.age += dt;
const a = S.slots[0], b = S.slots[1];
try {
if (!b && a.age >= PERIOD) addSlot(); // perekam kedua mulai saat yang pertama sudah punya PERIOD
else if (b && a.age >= 2 * PERIOD) killSlot(S.slots.shift()); // yang tertua berhenti dan dibuang; yang kedua jadi tertua (>= PERIOD), penggantinya mulai di poll berikutnya
} catch (e) { fail(e); }
}
function hold() {
if (S.state !== 'run') return;
S.state = 'hold'; stopPump(); badge();
for (const s of S.slots) { try { if (s.mr.state === 'recording') s.mr.pause(); } catch (_) { /* pause tidak didukung: bingkai tetap tidak masuk karena pump berhenti */ } }
}
function resumeAll() { for (const s of S.slots) { try { if (s.mr.state === 'paused') s.mr.resume(); } catch (_) { /* biarkan */ } } }
const abandon = () => { discardSlots(); stopPump(); S.state = 'idle'; badge(); };
function clearClip() {
const c = S.clip; S.clip = null;
if (c && c.url) { try { URL.revokeObjectURL(c.url); } catch (_) { /* tidak ada yang perlu dilepas */ } }
freeFrame(); paintUi();
}
function reset() { discardSlots(); stopPump(); S.state = 'idle'; S.fin = false; clearClip(); badge(); }
function collect(s) {
return new Promise(resolve => {
const mr = s.mr; let over = false, timer = 0;
const end = () => {
if (over) return; over = true; clearTimeout(timer);
mr.ondataavailable = mr.onstop = mr.onerror = null;
const chunks = s.chunks; s.chunks = [];
resolve(chunks.length ? new Blob(chunks, { type: 'video/webm' }) : null);
};
mr.onstop = end; mr.onerror = end; timer = setTimeout(end, 2500); // 'stop' datang sesudah dataavailable terakhir; batas waktu untuk jaga-jaga
try { if (mr.state === 'inactive') end(); else mr.stop(); } catch (_) { end(); }
});
}
// Pertandingan selesai: perekam tertua jadi klip (async), yang lain dibuang, bingkai terakhir dibekukan. Aman dipanggil berulang.
function finish() {
if (S.fin) return S.clip;
S.fin = true; stopPump();
const ds = arena.dataset, slots = S.slots.splice(0), old = slots.shift();
slots.forEach(killSlot);
const clip = S.clip = { id: ++S.seq, state: 'none', blob: null, url: '', player: ds.character || '', enemy: ds.opponent || '', dur: 0, ready: Promise.resolve() };
grabFrame(); S.state = 'done'; badge();
if (old && old.age >= MIN_MS) {
clip.state = 'wait'; clip.dur = Math.round(old.age) / 1000;
clip.ready = collect(old).then(blob => {
if (S.clip !== clip) return;
if (blob && blob.size >= MIN_BYTES) { clip.blob = blob; clip.state = 'ready'; } else clip.state = 'none';
paintUi();
}).catch(e => { warn(e); if (S.clip === clip) { clip.state = 'none'; paintUi(); } });
} else if (old) killSlot(old);
return clip;
}
function fail(e) {
warn(e); S.dead = true;
try { reset(); dropRec(); } catch (_) { /* pembersihan terbaik yang bisa */ }
for (const id of ['rec-block', 'rec-badge']) { const el = document.getElementById(id); if (el) el.remove(); }
S.badge = null; paintUi();
}
function badge() { if (S.badge) S.badge.hidden = !(cfg.badge && S.state === 'run'); }
// ---- pengawas: tiap 150 ms membaca keadaan permainan dari DOM ----
function poll() {
if (S.dead) return;
try {
const now = performance.now(), dt = Math.min(now - S.t, 500); S.t = now;
const ds = arena.dataset, menu = document.body.classList.contains('in-menu'), phase = ds.matchPhase || '', versus = ds.mode === 'versus';
const left = S.menu && !menu; S.menu = menu;
// Menu terbuka (termasuk layar hasil): rekaman yang belum selesai dibuang; klip yang sudah jadi tetap untuk layar hasil.
if (menu) { if (!S.fin) abandon(); return; }
const prev = S.phase; S.phase = phase;
// Pertandingan baru: menu depan menutup, atau Arcade/Survival lanjut tanpa menu (complete -> intro), atau mulai ulang dari jeda (fight -> intro).
if (left) { reset(); S.stale = true; } else if (phase === 'intro' && (prev === 'complete' || prev === 'fight')) reset();
if (S.stale) { if (phase === 'complete') return; S.stale = false; } // atribut #arena masih sisa pertandingan lama selama satu frame
if (!versus) { if (S.slots.length || S.clip || S.fin) reset(); return; }
if (phase === 'complete') { if (!S.fin) finish(); return; }
if (S.fin) return;
const modal = !!document.querySelector('dialog[open]'), cards = !!(window.VS && window.VS.active) || !!(window.Story && window.Story.active);
if (cfg.on && MIME && !document.hidden && !modal && !cards && (phase === 'intro' || phase === 'fight' || phase === 'ko')) run(dt); else hold();
} catch (e) { fail(e); }
}
// ---- layar hasil (kait vs.js) ----
const IC = {
cam: '<path d="M4 8h3l1.6-2.2h6.8L17 8h3v11H4z"/><circle cx="12" cy="13.2" r="3.4"/>',
dl: '<path d="M12 4v10m-4-3.6 4 4 4-4M5 19h14"/>',
play: '<path d="M8 5.5v13l10.5-6.5z"/>',
share: '<circle cx="6.5" cy="12" r="2.3"/><circle cx="17.5" cy="6.2" r="2.3"/><circle cx="17.5" cy="17.8" r="2.3"/><path d="m8.6 10.9 6.9-3.7m-6.9 5.9 6.9 3.7"/>'
};
const svg = k => `<svg class="rec-ic" viewBox="0 0 24 24" aria-hidden="true">${IC[k]}</svg>`;
const btn = (kind, icon, label, title, extra = '') => `<button type="button" class="rec-b" data-rec="${kind}" aria-label="${label}"${title ? ` title="${title}"` : ''}${extra}>${svg(icon)}<span class="t">${label}</span></button>`;
function bar(c) {
const has = !!c && c.state !== 'none', x = has && c.state !== 'ready' ? ' disabled aria-busy="true"' : '';
return '<div class="rs-rec" role="group" aria-label="Klip dan tangkapan layar">'
+ (has ? btn('clip', 'dl', 'SIMPAN KLIP', 'Simpan klip K.O. (.webm)', x) + btn('replay', 'play', 'TONTON ULANG', 'Putar ulang klip K.O.', x) : '')
+ btn('shot', 'cam', 'FOTO', 'Simpan tangkapan layar (F9)') + btn('share', 'share', 'BAGIKAN', 'Bagikan tangkapan layar') + '</div>';
}
function resultHtml(r) {
try {
if (!S.fin) finish(); // 'complete' dan layar hasil terjadi pada tick yang sama, sebelum poll berikutnya
const c = S.clip;
return bar(c && r && c.player === r.player && c.enemy === r.enemy ? c : null);
} catch (e) { warn(e); return ''; }
}
function paintUi() {
const st = S.clip ? S.clip.state : 'none';
document.querySelectorAll('.rs-rec').forEach(box => box.querySelectorAll('[data-rec="clip"],[data-rec="replay"]').forEach(b => {
if (st === 'none') { b.remove(); return; }
b.disabled = st !== 'ready'; if (st === 'ready') b.removeAttribute('aria-busy'); else b.setAttribute('aria-busy', 'true');
}));
}
function act(kind, b, frozen) {
try {
if (kind === 'shot') shotSave(b, frozen); else if (kind === 'share') shotShare(b, frozen);
else if (kind === 'clip') clipSave(); else if (kind === 'replay') replayOpen();
} catch (e) { warn(e); }
}
function mount(root) {
try {
const box = root && root.querySelector && root.querySelector('.rs-rec'); if (!box) return;
box.addEventListener('click', e => { const b = e.target.closest('[data-rec]'); if (b && !b.disabled) act(b.dataset.rec, b, true); });
if (S.clip && S.clip.state === 'wait') S.clip.ready.then(paintUi);
} catch (e) { warn(e); }
}
function clipSave() {
const c = S.clip; if (!c || c.state !== 'ready' || !c.blob) return;
save(c.blob, fileName('webm', true)); toast('Klip disimpan');
}
// ---- Tonton ulang: dialog modal (Esc, tombol B gamepad, dan fokus ditangani seperti dialog lain) ----
function replayDialog() {
let d = document.getElementById('rec-replay'); if (d) return d;
d = document.createElement('dialog'); d.id = 'rec-replay'; d.setAttribute('aria-labelledby', 'rec-replay-t');
d.innerHTML = '<div class="dialog-kicker">KONOHA FIGHTING / K.O.</div><h2 id="rec-replay-t">TONTON ULANG</h2><video playsinline muted preload="auto"></video>'
+ '<div class="rp-bar"><button type="button" data-rp="toggle">JEDA</button><button type="button" data-rp="again">ULANGI</button><button type="button" data-rp="save">SIMPAN KLIP</button><button type="button" data-rp="close">TUTUP</button></div>';
document.body.append(d);
const v = d.querySelector('video'), t = d.querySelector('[data-rp="toggle"]');
const sync = () => { t.textContent = v.paused || v.ended ? 'PUTAR' : 'JEDA'; };
v.addEventListener('play', sync); v.addEventListener('pause', sync); v.addEventListener('ended', sync);
const toggle = () => { if (v.paused || v.ended) { const p = v.play(); if (p && p.catch) p.catch(() => {}); } else v.pause(); };
v.addEventListener('click', toggle);
d.addEventListener('click', e => {
if (e.target === d) { const r = d.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) replayClose(); return; } // klik di latar, bukan di padding dialog
const k = e.target.closest('[data-rp]') && e.target.closest('[data-rp]').dataset.rp;
if (k === 'toggle') toggle();
else if (k === 'again') { v.currentTime = 0; const p = v.play(); if (p && p.catch) p.catch(() => {}); }
else if (k === 'save') clipSave();
else if (k === 'close') replayClose();
});
d.addEventListener('cancel', e => { e.preventDefault(); replayClose(); }); // Esc e tombol B gamepad (input.js mengirim 'cancel')
return d;
}
function replayOpen() {
const c = S.clip; if (!c || c.state !== 'ready' || !c.blob) return;
const d = replayDialog(), v = d.querySelector('video');
d.querySelector('.dialog-kicker').textContent = 'KONOHA FIGHTING / K.O.' + (c.dur >= 1 ? ' · ' + Math.round(c.dur) + ' DETIK' : '');
if (!c.url) c.url = URL.createObjectURL(c.blob);
v.src = c.url; v.muted = true; try { v.currentTime = 0; } catch (_) { /* belum ada metadata */ }
if (!d.open) d.showModal();
const p = v.play(); if (p && p.catch) p.catch(() => {});
}
function replayClose() {
const d = document.getElementById('rec-replay'); if (!d) return;
const v = d.querySelector('video');
try { v.pause(); } catch (_) { /* tidak ada yang diputar */ }
if (d.open) d.close();
v.removeAttribute('src'); try { v.load(); } catch (_) { /* biarkan */ }
}
// ---- pasang elemen: HUD, jeda, Pengaturan ----
function mountHud() {
const nav = $('.match-actions'); if (!nav || nav.querySelector('#rec-shot')) return;
const b = document.createElement('button'); b.type = 'button'; b.id = 'rec-shot'; b.setAttribute('aria-label', 'Tangkap layar (F9)'); b.title = 'Tangkap layar (F9)';
b.innerHTML = `<svg viewBox="0 0 24 24" aria-hidden="true">${IC.cam}</svg>`;
b.onclick = () => { shotSave(b, false); refocus(); };
nav.append(b);
if (MIME) {
const t = document.createElement('span'); t.id = 'rec-badge'; t.className = 'rec-badge'; t.hidden = true; t.setAttribute('aria-hidden', 'true'); t.innerHTML = '<i></i>REC';
nav.append(t); S.badge = t;
}
}
function mountPause() {
const main = $('#pause-panel .pause-main'); if (!main || main.querySelector('.rec-btns')) return;
const row = document.createElement('div'); row.className = 'rec-btns';
row.innerHTML = btn('shot', 'cam', 'TANGKAP LAYAR', 'Simpan tangkapan layar (F9)') + btn('share', 'share', 'BAGIKAN', 'Bagikan tangkapan layar');
row.addEventListener('click', e => { const b = e.target.closest('[data-rec]'); if (b) act(b.dataset.rec, b, false); });
main.append(row);
}
function mountSettings() {
const panel = $('#settings-panel'); if (!panel || !MIME || panel.querySelector('#rec-row')) return;
const box = document.createElement('div'); box.id = 'rec-block';
box.innerHTML = '<label class="setting-row" id="rec-row"><span>Rekam ulang K.O.<small>Menyimpan sekitar 6 sampai 12 detik terakhir tiap pertandingan untuk klip di layar hasil, hanya di perangkat ini</small></span><input type="checkbox" id="rec-toggle"></label>'
+ '<label class="setting-row" id="rec-badge-row"><span>Indikator REC<small>Titik merah kecil di bawah tombol arena saat merekam</small></span><input type="checkbox" id="rec-badge-toggle"></label>';
const vol = panel.querySelector('#volume-slider'), anchor = vol && vol.closest('.volume-row');
if (anchor) anchor.after(box); else (panel.querySelector('.dialog-buttons') || panel.lastElementChild).before(box);
const on = box.querySelector('#rec-toggle'), bd = box.querySelector('#rec-badge-toggle');
on.checked = cfg.on; bd.checked = cfg.badge; bd.disabled = !cfg.on;
on.onchange = () => {
cfg.on = on.checked; bd.disabled = !cfg.on; saveCfg();
if (!cfg.on) { discardSlots(); stopPump(); S.state = S.fin ? 'done' : 'idle'; } // klip yang sudah jadi tidak dibuang
badge();
};
bd.onchange = () => { cfg.badge = bd.checked; saveCfg(); badge(); };
}
// F9 = tangkapan layar di arena (termasuk saat dijeda) dan di layar hasil; di tempat lain dibiarkan untuk peramban. Esc menutup Tonton ulang lebih dulu daripada menu di belakangnya.
function place() {
if (!arena.width) return '';
if (!document.body.classList.contains('in-menu')) return window.VS?.active || window.Story?.active ? '' : 'arena';
const fm = $('#front-menu');
return fm && fm.dataset.screen === 'result' && !document.querySelector('dialog[open]') ? 'result' : '';
}
window.addEventListener('keydown', e => {
const d = document.getElementById('rec-replay');
if (d && d.open) {
if (e.key === 'Escape') { e.preventDefault(); e.stopImmediatePropagation(); replayClose(); }
else if (e.key === ' ' && e.target.tagName !== 'BUTTON') { e.preventDefault(); e.stopImmediatePropagation(); d.querySelector('[data-rp="toggle"]').click(); }
return;
}
if (e.key !== 'F9' || e.repeat || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
const where = place(); if (!where) return;
e.preventDefault(); e.stopImmediatePropagation();
shotSave(null, where === 'result');
}, true);
// Pertandingan baru memulai sesi baru seketika (poll hanya cadangan): tanpa ini hasil yang dipasang sebelum poll berikutnya bisa memakai klip pertandingan sebelumnya.
function hookStart() {
const g = window.__game; if (!g || typeof g.startMatch !== 'function' || g.startMatch.__rec) return;
const base = g.startMatch;
const wrapped = function () {
const ok = base.apply(this, arguments);
try { if (ok) { reset(); S.stale = true; } } catch (_) { /* tidak boleh mengganggu mulai pertandingan */ }
return ok;
};
wrapped.__rec = true; g.startMatch = wrapped;
}
function init() {
try { mountHud(); mountPause(); mountSettings(); hookStart(); } catch (e) { warn(e); }
S.t = performance.now();
setInterval(poll, POLL);
document.addEventListener('visibilitychange', poll);
window.addEventListener('pagehide', () => { try { reset(); } catch (_) { /* halaman sedang ditutup */ } });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
window.Record = {
get supported() { return !!MIME && !S.dead; },
get enabled() { return cfg.on; },
set(on) { cfg.on = !!on; saveCfg(); const t = $('#rec-toggle'); if (t) { t.checked = cfg.on; t.onchange(); } return cfg.on; },
state() { return { supported: !!MIME, dead: S.dead, on: cfg.on, mime: MIME, state: S.state, fin: S.fin, ages: S.slots.map(s => Math.round(s.age)), clip: S.clip && { state: S.clip.state, dur: S.clip.dur, bytes: S.clip.blob ? S.clip.blob.size : 0, player: S.clip.player, enemy: S.clip.enemy } }; },
resultHtml, mount,
shot(frozen) { return shotSave(null, !!frozen); },
share(frozen) { return shotShare(null, !!frozen); },
saveClip: clipSave, replay: replayOpen
};
})();