| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> |
| <title>FIDA Controller & OBS</title> |
| <script src="https://cdn.tailwindcss.com"></script> |
| <link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Inter:wght@300;400;600&display=swap" rel="stylesheet"> |
| <style> |
| |
| body { |
| font-family: 'Inter', sans-serif; |
| background-color: #121212; |
| color: white; |
| height: 100vh; |
| overflow: hidden; |
| user-select: none; |
| margin: 0; |
| } |
| .orbitron { font-family: 'Orbitron', sans-serif; } |
| .wire-border { border: 1px solid rgba(255, 255, 255, 0.3); } |
| .scrollbar-hide::-webkit-scrollbar { display: none; } |
| |
| |
| #obsView { |
| display: none; |
| position: fixed; |
| top: 0; left: 0; width: 100%; height: 100%; |
| background: #000; |
| align-items: center; |
| justify-content: center; |
| z-index: 9999; |
| } |
| #display-value { |
| font-family: 'Orbitron', sans-serif; |
| color: #fff; |
| font-size: 50vh; |
| font-weight: 900; |
| line-height: 1; |
| text-align: center; |
| white-space: nowrap; |
| font-variant-numeric: tabular-nums; |
| } |
| |
| |
| .team-select-embedded { |
| background: transparent; color: white; font-weight: 700; |
| text-align: center; width: 100%; outline: none; appearance: none; |
| border: none; text-transform: uppercase; |
| } |
| .team-select-embedded option { background-color: #121212; } |
| .tab-active-red { background-color: rgba(220, 38, 38, 0.3); border-bottom: 2px solid #ef4444; } |
| .tab-active-blue { background-color: rgba(37, 99, 235, 0.3); border-bottom: 2px solid #3b82f6; } |
| .play-circle { |
| width: 140px; height: 140px; border: 2px solid white; border-radius: 50%; |
| display: flex; align-items: center; justify-content: center; transition: transform 0.1s; |
| } |
| .play-circle:active { transform: scale(0.95); background: rgba(255,255,255,0.1); } |
| .pilot-card.selected { background-color: rgba(255,255,255,0.3); border-color: white; } |
| </style> |
| </head> |
| <body> |
|
|
| |
| <div id="controller-ui" class="flex flex-col h-full w-full p-2 gap-2"> |
| |
| <div class="h-16 flex gap-2 shrink-0"> |
| <button onclick="toggleMode()" class="w-1/4 wire-border flex flex-col items-center justify-center hover:bg-white/10"> |
| <span class="text-[10px] text-gray-400">MODE</span> |
| <span id="modeLabel" class="font-bold orbitron text-sm">MATCH</span> |
| </button> |
|
|
| |
| <div id="topContextMatch" class="w-3/4 flex gap-2"> |
| <div class="flex-1 wire-border flex items-center justify-between px-2"> |
| <button onclick="updateSet(-1)" class="text-3xl font-bold px-4 hover:text-gray-300">-</button> |
| <div class="text-center"> |
| <div class="text-[10px] text-gray-400">SET</div> |
| <div id="currentSet" class="text-3xl font-bold orbitron">1</div> |
| </div> |
| <button onclick="updateSet(1)" class="text-3xl font-bold px-4 hover:text-gray-300">+</button> |
| </div> |
| <button onclick="saveMatchToHistory()" id="btnSaveMatch" class="w-16 wire-border flex items-center justify-center hover:bg-white/10"> |
| 💾 |
| </button> |
| </div> |
|
|
| |
| <div id="topContextPilot" class="w-3/4 wire-border hidden flex-col justify-center px-2 relative"> |
| <select id="spTeamSelect" onchange="loadPilots(this.value)" class="bg-black text-center font-bold text-lg outline-none w-full uppercase"> |
| <option value="">-- SELECT TEAM --</option> |
| </select> |
| </div> |
| </div> |
|
|
| |
| <div id="matchInterface" class="flex-1 flex flex-col gap-2"> |
| |
| |
| <div class="h-24 wire-border flex items-center justify-between px-4 bg-white/5"> |
| <div class="flex flex-col"> |
| <span class="text-[10px] text-gray-400">MATCH TIMER</span> |
| <div id="ctrlMatchTimer" class="text-5xl font-bold orbitron text-yellow-500 tracking-wider">03:00</div> |
| </div> |
| <div class="flex gap-2"> |
| <button onclick="toggleMatchTimer()" class="w-12 h-12 border border-white/20 flex items-center justify-center hover:bg-white/10"> |
| <svg id="mtPlay" class="w-6 h-6 fill-white" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> |
| <svg id="mtPause" class="w-6 h-6 fill-white hidden" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg> |
| </button> |
| <button onclick="resetMatchTimer()" class="w-12 h-12 border border-white/20 flex items-center justify-center hover:bg-white/10"> |
| ↺ |
| </button> |
| <button onclick="copyLink('timer')" id="btnLinkTimer" class="w-16 h-12 border border-white/20 flex flex-col items-center justify-center hover:bg-white/10 text-[10px]"> |
| 🔗 LINK |
| </button> |
| </div> |
| </div> |
|
|
| |
| <div class="h-14 flex gap-2"> |
| <div id="tabRed" onclick="selectActiveTeam('red')" class="w-1/2 wire-border flex flex-col items-center justify-center relative transition-all"> |
| <span class="text-[10px] opacity-70">RED <span id="winsRed">(0)</span></span> |
| <select id="selectRedTeam" onchange="changeMatchTeam('red', this.value)" class="team-select-embedded"><option>Loading...</option></select> |
| </div> |
| <div id="tabBlue" onclick="selectActiveTeam('blue')" class="w-1/2 wire-border flex flex-col items-center justify-center relative transition-all opacity-50"> |
| <span class="text-[10px] opacity-70">BLUE <span id="winsBlue">(0)</span></span> |
| <select id="selectBlueTeam" onchange="changeMatchTeam('blue', this.value)" class="team-select-embedded"><option>Loading...</option></select> |
| </div> |
| </div> |
|
|
| |
| <div class="flex-1 wire-border relative flex flex-col justify-evenly bg-white/5"> |
| |
| <div class="flex items-center justify-center gap-6"> |
| <button class="text-4xl px-4 py-2" onclick="updateMatchValue('points', -1)">-</button> |
| <div class="text-center min-w-[120px]"> |
| <div class="text-[10px] text-gray-400">POINTS</div> |
| <div id="displayPoints" class="text-5xl font-bold orbitron">0</div> |
| </div> |
| <button class="text-4xl px-4 py-2" onclick="updateMatchValue('points', 1)">+</button> |
| </div> |
| |
| <div class="flex items-center justify-center gap-6"> |
| <button class="text-4xl px-4 py-2" onclick="updateMatchValue('penalty', -1)">-</button> |
| <div class="text-center min-w-[120px]"> |
| <div class="text-[10px] text-gray-400">PENALTY</div> |
| <div id="displayPenalty" class="text-5xl font-bold orbitron">0</div> |
| </div> |
| <button class="text-4xl px-4 py-2" onclick="updateMatchValue('penalty', 1)">+</button> |
| </div> |
| |
| <div class="text-center border-t border-white/10 pt-2"> |
| <div class="text-[10px] text-gray-400">SCORE</div> |
| <div id="displayTotalScore" class="text-4xl font-black orbitron text-yellow-400">0.0</div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="superpilotInterface" class="hidden flex-1 flex-col gap-4 py-2"> |
| <div id="pilotList" class="flex gap-3 overflow-x-auto h-32 shrink-0 scrollbar-hide px-1 border border-white/10 p-1"> |
| <div class="w-full h-full flex items-center justify-center text-gray-500 text-sm">Select Team First</div> |
| </div> |
|
|
| <div class="flex-1 flex flex-col items-center justify-center"> |
| <div class="text-[10px] text-gray-400 mb-2">PILOT STOPWATCH</div> |
| <div id="ctrlStopwatch" class="text-6xl font-bold orbitron tracking-wider mb-6">00:00:00</div> |
| |
| <button onclick="toggleStopwatch()" id="btnSpAction" class="play-circle hover:bg-white/10"> |
| <svg id="spPlay" class="w-16 h-16 fill-white ml-2" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg> |
| <svg id="spPause" class="w-16 h-16 fill-white hidden" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg> |
| </button> |
|
|
| <div class="flex gap-4 mt-8 w-full px-4"> |
| <button onclick="copyLink('stopwatch')" id="btnLinkStopwatch" class="flex-1 h-12 border border-white/20 font-bold text-xs hover:bg-white/10"> |
| COPY OBS LINK |
| </button> |
| <button onclick="saveSuperpilot()" class="flex-1 h-12 wire-border bg-green-900/30 font-bold text-xs hover:bg-green-900/50"> |
| SAVE RESULT |
| </button> |
| </div> |
| <div id="spMsg" class="h-4 text-green-400 text-xs font-bold mt-2"></div> |
| </div> |
| </div> |
| </div> |
|
|
| |
| <div id="obsView"><div id="display-value">00:00</div></div> |
|
|
| <script type="module"> |
| import { initializeApp } from "https://www.gstatic.com/firebasejs/10.8.0/firebase-app.js"; |
| import { getDatabase, ref, onValue, update, push, set } from "https://www.gstatic.com/firebasejs/10.8.0/firebase-database.js"; |
| |
| const firebaseConfig = { |
| apiKey: "AIzaSyDi6znbXgOFR7aXZ9v7aVFP065yE98T7XU", |
| authDomain: "fida-kz.firebaseapp.com", |
| databaseURL: "https://fida-kz-default-rtdb.firebaseio.com", |
| projectId: "fida-kz", |
| appId: "1:728249202318:web:29ee414ef7eff47573fbdf" |
| }; |
| const app = initializeApp(firebaseConfig); |
| const db = getDatabase(app); |
| |
| let mode = "match"; |
| let activeTeam = "red"; |
| let teamsCache = {}; |
| let matchData = {}; |
| let selectedPilot = null; |
| const MATCH_DURATION = 120; |
| |
| |
| const params = new URLSearchParams(window.location.search); |
| const viewMode = params.get('view'); |
| if (viewMode) { |
| document.getElementById('controller-ui').style.display = 'none'; |
| document.getElementById('obsView').style.display = 'flex'; |
| document.body.style.backgroundColor = 'black'; |
| } |
| |
| |
| let pilotTimerState = { running: false, startAt: 0, accum: 0 }; |
| let matchTimerState = { running: false, target: 0, remaining: MATCH_DURATION }; |
| |
| function animationLoop() { |
| const now = Date.now(); |
| let matchSec = matchTimerState.remaining; |
| if (matchTimerState.running) { |
| matchSec = Math.max(0, matchTimerState.target - (now / 1000)); |
| } |
| const mtStr = formatTime(matchSec, false); |
| let pilotMs = pilotTimerState.accum; |
| if (pilotTimerState.running) { |
| pilotMs = pilotTimerState.accum + (now - pilotTimerState.startAt); |
| } |
| const spStr = formatTime(pilotMs / 1000, true); |
| |
| if (viewMode === 'timer') document.getElementById('display-value').innerText = mtStr; |
| else if (viewMode === 'stopwatch') document.getElementById('display-value').innerText = spStr; |
| else { |
| const elM = document.getElementById('ctrlMatchTimer'); |
| const elP = document.getElementById('ctrlStopwatch'); |
| if (elM) elM.innerText = mtStr; |
| if (elP) elP.innerText = spStr; |
| } |
| requestAnimationFrame(animationLoop); |
| } |
| requestAnimationFrame(animationLoop); |
| |
| function formatTime(totalSeconds, includeMs) { |
| const m = Math.floor(totalSeconds / 60); |
| const s = Math.floor(totalSeconds % 60); |
| const ms = Math.floor((totalSeconds % 1) * 100); |
| let str = `${m.toString().padStart(2,'0')}:${s.toString().padStart(2,'0')}`; |
| if (includeMs) str += `:${ms.toString().padStart(2,'0')}`; |
| return str; |
| } |
| |
| |
| onValue(ref(db, 'active_match'), (snap) => { |
| matchData = snap.val() || {}; |
| |
| if (matchData.match_timer) { |
| matchTimerState.running = matchData.match_timer.running || false; |
| matchTimerState.target = matchData.match_timer.target_time || 0; |
| matchTimerState.remaining = (matchData.match_timer.remaining !== undefined) ? matchData.match_timer.remaining : MATCH_DURATION; |
| } |
| if (matchData.meta) { |
| const s = matchData.meta.seconds || 0; |
| const us = matchData.meta.microseconds || 0; |
| pilotTimerState.accum = (s * 1000) + us; |
| pilotTimerState.startAt = matchData.meta.started || 0; |
| pilotTimerState.running = (matchData.meta.started > 0); |
| } |
| updateButtonStates(); |
| if (!viewMode) updateControllerUI(matchData); |
| }); |
| |
| onValue(ref(db, 'teams'), (snap) => { |
| teamsCache = snap.val() || {}; |
| if (!viewMode) populateTeams(); |
| }); |
| |
| |
| |
| window.updateSet = (delta) => { |
| const current = (matchData && matchData.current_set) ? parseInt(matchData.current_set) : 1; |
| const next = Math.max(1, current + delta); |
| |
| const updates = {}; |
| updates['active_match/current_set'] = next; |
| |
| |
| const emptySet = { |
| team_red: { points: 0, penalty: 0, score: 0 }, |
| team_blue: { points: 0, penalty: 0, score: 0 } |
| }; |
| |
| |
| const nextSetData = (matchData.meta && matchData.meta.sets && matchData.meta.sets[next]) |
| ? matchData.meta.sets[next] |
| : emptySet; |
| |
| |
| if (!matchData.meta?.sets?.[next]) { |
| updates[`active_match/meta/sets/${next}`] = emptySet; |
| } |
| |
| |
| updates[`active_match/current_set_data`] = nextSetData; |
| |
| update(ref(db), updates).catch(console.error); |
| }; |
| |
| window.updateMatchValue = (type, delta) => { |
| const set = matchData.current_set || 1; |
| const key = `team_${activeTeam}`; |
| const pathHistory = `active_match/meta/sets/${set}/${key}`; |
| const pathMirror = `active_match/current_set_data/${key}`; |
| |
| const currentData = matchData.meta?.sets?.[set]?.[key] || {}; |
| let pts = currentData.points || 0; |
| let pen = currentData.penalty || 0; |
| |
| if (type === 'points') pts = Math.max(0, pts + delta); |
| if (type === 'penalty') pen = Math.max(0, pen + delta); |
| const score = parseFloat((pts + (pen * 0.8)).toFixed(2)); |
| |
| |
| const updates = {}; |
| |
| updates[`${pathHistory}/points`] = pts; |
| updates[`${pathHistory}/penalty`] = pen; |
| updates[`${pathHistory}/score`] = score; |
| |
| updates[`${pathMirror}/points`] = pts; |
| updates[`${pathMirror}/penalty`] = pen; |
| updates[`${pathMirror}/score`] = score; |
| |
| update(ref(db), updates).then(() => calculateWins()); |
| }; |
| |
| |
| |
| window.toggleMatchTimer = () => { |
| const nowSec = Date.now() / 1000; |
| if (matchTimerState.running) { |
| const left = Math.max(0, matchTimerState.target - nowSec); |
| update(ref(db, 'active_match/match_timer'), { running: false, remaining: left, target_time: 0 }); |
| } else { |
| const target = nowSec + matchTimerState.remaining; |
| update(ref(db, 'active_match/match_timer'), { running: true, target_time: target }); |
| } |
| }; |
| |
| window.resetMatchTimer = () => { |
| update(ref(db, 'active_match/match_timer'), { running: false, remaining: MATCH_DURATION, target_time: 0 }); |
| }; |
| |
| window.toggleStopwatch = () => { |
| if (!selectedPilot && !pilotTimerState.running && pilotTimerState.accum === 0) { |
| alert("SELECT PILOT FIRST!"); return; |
| } |
| const now = Date.now(); |
| if (pilotTimerState.running) { |
| const currentAccum = pilotTimerState.accum + (now - pilotTimerState.startAt); |
| update(ref(db, 'active_match/meta'), { started: 0, seconds: Math.floor(currentAccum / 1000), microseconds: currentAccum % 1000 }); |
| } else { |
| update(ref(db, 'active_match/meta'), { started: now }); |
| } |
| }; |
| |
| window.saveSuperpilot = () => { |
| if (!selectedPilot) return; |
| const now = Date.now(); |
| let finalMs = pilotTimerState.accum; |
| if (pilotTimerState.running) finalMs += (now - pilotTimerState.startAt); |
| const record = { |
| uid: selectedPilot.id, name: selectedPilot.name, team_name: selectedPilot.teamName, |
| image: selectedPilot.image, seconds: Math.floor(finalMs / 1000), milliseconds: finalMs, |
| started: Math.floor(now / 1000) |
| }; |
| const msg = document.getElementById('spMsg'); |
| msg.innerText = "SAVING..."; |
| push(ref(db, 'history/eventkey1/superpilot'), record).then(() => { |
| msg.innerText = "SAVED!"; |
| setTimeout(() => msg.innerText = "", 2000); |
| update(ref(db, 'active_match/meta'), { started: 0, seconds: 0, microseconds: 0 }); |
| }); |
| }; |
| |
| function calculateWins() { |
| let red = 0, blue = 0; |
| const sets = matchData.meta?.sets || {}; |
| Object.values(sets).forEach(s => { |
| if (s && s.team_red && s.team_blue) { |
| if (s.team_red.score > s.team_blue.score) red++; |
| if (s.team_blue.score > s.team_red.score) blue++; |
| } |
| }); |
| update(ref(db, 'active_match/team_red/sets_won'), red); |
| update(ref(db, 'active_match/team_blue/sets_won'), blue); |
| } |
| |
| window.saveMatchToHistory = () => { |
| const record = { |
| timestamp: Date.now(), |
| meta: matchData.meta || {}, |
| team_red: matchData.team_red, |
| team_blue: matchData.team_blue |
| }; |
| push(ref(db, 'history/eventkey1/match'), record).then(() => alert('Match Saved!')); |
| }; |
| |
| window.toggleMode = () => { |
| mode = (mode === 'match') ? 'pilot' : 'match'; |
| document.getElementById('matchInterface').classList.toggle('hidden', mode !== 'match'); |
| document.getElementById('matchInterface').classList.toggle('flex', mode === 'match'); |
| document.getElementById('topContextMatch').classList.toggle('hidden', mode !== 'match'); |
| document.getElementById('topContextMatch').classList.toggle('flex', mode === 'match'); |
| document.getElementById('superpilotInterface').classList.toggle('hidden', mode !== 'pilot'); |
| document.getElementById('superpilotInterface').classList.toggle('flex', mode === 'pilot'); |
| document.getElementById('topContextPilot').classList.toggle('hidden', mode !== 'pilot'); |
| document.getElementById('topContextPilot').classList.toggle('flex', mode === 'pilot'); |
| document.getElementById('modeLabel').innerText = mode.toUpperCase(); |
| }; |
| |
| window.selectActiveTeam = (t) => { activeTeam = t; updateControllerUI(matchData); }; |
| |
| window.loadPilots = (teamId) => { |
| const list = document.getElementById('pilotList'); |
| list.innerHTML = ""; |
| selectedPilot = null; |
| update(ref(db, 'active_match/meta'), { started: 0, seconds: 0, microseconds: 0 }); |
| if (!teamId || !teamsCache[teamId]) return; |
| const team = teamsCache[teamId]; |
| const roster = team.active_roster || Object.values(team.members || {}); |
| roster.forEach((p, idx) => { |
| const div = document.createElement('div'); |
| div.className = "min-w-[100px] h-full border border-gray-600 bg-black cursor-pointer pilot-card flex flex-col justify-end relative"; |
| if(p.image) div.style = `background-image: url('${p.image}'); background-size: cover;`; |
| div.innerHTML = `<div class="bg-black/70 text-center text-xs p-1 truncate text-white w-full">${p.name}</div>`; |
| div.onclick = () => { |
| document.querySelectorAll('.pilot-card').forEach(el => el.classList.remove('selected')); |
| div.classList.add('selected'); |
| selectedPilot = { ...p, id: p.id || `tmp_${idx}`, teamName: team.name }; |
| update(ref(db, 'active_match/meta'), { started: 0, seconds: 0, microseconds: 0 }); |
| update(ref(db, 'active_match/pilot'), { name: p.name, image: p.image || "", team_name: team.name }); |
| }; |
| list.appendChild(div); |
| }); |
| }; |
| |
| window.copyLink = (type) => { |
| const url = `${window.location.origin}${window.location.pathname}?view=${type}`; |
| navigator.clipboard.writeText(url); |
| const btn = document.getElementById(type === 'timer' ? 'btnLinkTimer' : 'btnLinkStopwatch'); |
| const old = btn.innerText; |
| btn.innerText = "COPIED"; |
| setTimeout(() => btn.innerText = old, 1500); |
| }; |
| |
| window.changeMatchTeam = (color, id) => { |
| if(!teamsCache[id]) return; |
| const t = teamsCache[id]; |
| set(ref(db, `active_match/team_${color}`), { |
| id: id, name: t.name, logo: t.logo||"", |
| roster: t.active_roster || Object.values(t.members||{}), |
| sets_won: 0, warnings: 0 |
| }); |
| }; |
| |
| function populateTeams() { |
| const opts = ['<option value="">-- CHOOSE --</option>']; |
| Object.entries(teamsCache).forEach(([k,v]) => opts.push(`<option value="${k}">${v.name}</option>`)); |
| const els = ['selectRedTeam', 'selectBlueTeam', 'spTeamSelect']; |
| els.forEach(id => { |
| const el = document.getElementById(id); |
| if(el && el.children.length <= 1) el.innerHTML = opts.join(''); |
| }); |
| } |
| |
| function updateButtonStates() { |
| const mtPlay = document.getElementById('mtPlay'); |
| const mtPause = document.getElementById('mtPause'); |
| if(mtPlay) { |
| if(matchTimerState.running) { mtPlay.classList.add('hidden'); mtPause.classList.remove('hidden'); } |
| else { mtPlay.classList.remove('hidden'); mtPause.classList.add('hidden'); } |
| } |
| const spPlay = document.getElementById('spPlay'); |
| const spPause = document.getElementById('spPause'); |
| if(spPlay) { |
| if(pilotTimerState.running) { spPlay.classList.add('hidden'); spPause.classList.remove('hidden'); } |
| else { spPlay.classList.remove('hidden'); spPause.classList.add('hidden'); } |
| } |
| } |
| |
| function updateControllerUI(data) { |
| const r = document.getElementById('tabRed'); |
| const b = document.getElementById('tabBlue'); |
| if(activeTeam === 'red') { |
| r.className = r.className.replace('opacity-50', 'tab-active-red'); |
| b.className = b.className.replace('tab-active-blue', '').replace('tab-active-red', '') + ' opacity-50'; |
| } else { |
| b.className = b.className.replace('opacity-50', 'tab-active-blue'); |
| r.className = r.className.replace('tab-active-blue', '').replace('tab-active-red', '') + ' opacity-50'; |
| } |
| const set = data.current_set || 1; |
| document.getElementById('currentSet').innerText = set; |
| const setData = data.meta?.sets?.[set]?.[`team_${activeTeam}`] || {}; |
| document.getElementById('displayPoints').innerText = setData.points || 0; |
| document.getElementById('displayPenalty').innerText = setData.penalty || 0; |
| document.getElementById('displayTotalScore').innerText = (setData.score || 0).toFixed(1); |
| document.getElementById('winsRed').innerText = `(${data.team_red?.sets_won || 0})`; |
| document.getElementById('winsBlue').innerText = `(${data.team_blue?.sets_won || 0})`; |
| } |
| </script> |
| </body> |
| </html> |