Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>LittleBunnyPaws' Magic Co-Pilot</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&family=Mochiy+Pop+One&display=swap" rel="stylesheet"> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/tone/14.7.77/Tone.js"></script> | |
| <style> | |
| body { font-family: 'Inter', sans-serif; background-color: #fdf2f8; } /* pink-50 */ | |
| .font-brand { font-family: 'Mochiy Pop One', sans-serif; } | |
| .hidden { display: none; } | |
| .tip-bar-progress { transition: width 0.5s ease-in-out; } | |
| .input-glow { transition: box-shadow 0.3s ease; } | |
| .input-glow:focus { box-shadow: 0 0 0 3px rgba(236, 72, 153, 0.4); } /* pink-500 with opacity */ | |
| </style> | |
| </head> | |
| <body> | |
| <!-- =================================================================== --> | |
| <!-- SECTION 1: INITIAL SETUP (Ask for username) --> | |
| <!-- =================================================================== --> | |
| <div id="setup-view" class="flex items-center justify-center min-h-screen"> | |
| <div class="w-full max-w-md mx-auto bg-white rounded-2xl shadow-xl p-8 border-4 border-pink-200 text-center"> | |
| <h1 class="font-brand text-4xl text-pink-500 mb-4">🐰 Welcome! 🐰</h1> | |
| <p class="text-gray-600 mb-6">Let's get your stream co-pilot ready. Please enter your Chaturbate username below.</p> | |
| <input type="text" id="username-input" placeholder="e.g., littlebunnypaws" class="w-full p-3 text-center border-2 border-gray-300 rounded-lg mb-4 text-lg input-glow focus:outline-none focus:border-pink-400"> | |
| <button id="start-button" class="w-full bg-pink-500 hover:bg-pink-600 text-white font-bold py-3 px-6 rounded-lg shadow-md text-xl">Start Co-Pilot</button> | |
| </div> | |
| </div> | |
| <!-- =================================================================== --> | |
| <!-- SECTION 2: CONTROL PANEL VIEW (Her private controls) --> | |
| <!-- =================================================================== --> | |
| <div id="control-panel-view" class="hidden p-4"> | |
| <div class="w-full max-w-4xl mx-auto bg-white rounded-2xl shadow-xl p-6 border-4 border-pink-200"> | |
| <header class="text-center mb-4"> | |
| <h1 class="font-brand text-3xl md:text-4xl text-pink-500">🐰 Stream Co-Pilot 🐰</h1> | |
| <p class="text-gray-600">You are connected to <strong id="display-username" class="text-pink-600"></strong>'s chat.</p> | |
| </header> | |
| <div class="bg-blue-100 border-l-4 border-blue-500 text-blue-700 p-4 rounded-lg mb-6"> | |
| <h3 class="font-bold">Your OBS Overlay Link</h3> | |
| <p class="text-sm">Copy this link and paste it into a new Browser Source in OBS. This is what your viewers will see.</p> | |
| <input type="text" id="obs-link-display" readonly class="w-full bg-blue-50 p-2 mt-2 rounded border border-blue-300 select-all"> | |
| </div> | |
| <div class="bg-pink-100 rounded-xl p-4 my-6 text-center"> | |
| <div id="group-timer-display" class="font-brand text-7xl md:text-8xl text-pink-600 tracking-wider">00:00</div> | |
| </div> | |
| <div id="group-status-message" class="text-center text-lg font-semibold text-gray-600 h-8 mb-4">Set up your stream below!</div> | |
| <div class="grid md:grid-cols-2 gap-6"> | |
| <!-- Live Controls --> | |
| <div class="bg-gray-50 rounded-xl p-4"> | |
| <h3 class="font-brand text-xl text-center text-gray-700 mb-4">Live Controls</h3> | |
| <div class="flex items-center justify-center gap-4 mb-4"> | |
| <input type="number" id="group-minutes" value="5" min="1" class="w-24 p-2 text-center border-2 border-gray-300 rounded-lg" placeholder="Mins"> | |
| <button id="group-start-btn" class="w-full bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-6 rounded-lg shadow-md">Start</button> | |
| <button id="group-stop-btn" class="w-full bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-6 rounded-lg shadow-md hidden">Reset</button> | |
| </div> | |
| <div class="text-center border-t-2 border-gray-200 pt-4 mt-4"> | |
| <p class="font-semibold text-gray-700 mb-3">🛠️ Manual Tip Override 🛠️</p> | |
| <div id="dynamic-tip-buttons-container" class="flex flex-wrap justify-center gap-3"><p class="text-gray-500 text-sm">Create tip rules to add manual buttons.</p></div> | |
| </div> | |
| </div> | |
| <!-- Setup --> | |
| <div class="bg-gray-100 rounded-xl p-4"> | |
| <h3 class="font-brand text-xl text-center text-gray-700 mb-4">Stream Setup</h3> | |
| <div class="space-y-4"> | |
| <div> | |
| <label for="tip-goal-input" class="block font-semibold text-gray-700 mb-1">Community Tip Goal</label> | |
| <div class="flex items-center gap-2"> | |
| <input type="number" id="tip-goal-input" value="1000" class="w-full p-2 border-gray-300 rounded-lg"> | |
| <button id="set-goal-btn" class="bg-pink-500 text-white font-bold py-2 px-4 rounded-lg">Set</button> | |
| </div> | |
| </div> | |
| <div> | |
| <label for="sound-select" class="block font-semibold text-gray-700 mb-1">Tip Sound Alert</label> | |
| <select id="sound-select" class="w-full p-2 border-gray-300 rounded-lg"> | |
| <option value="chime">Chime</option><option value="coin">Coin</option><option value="powerup">Power Up</option><option value="boop">Boop</option><option value="none">None</option> | |
| </select> | |
| </div> | |
| <div class="border-t-2 pt-4"> | |
| <label class="block font-semibold text-gray-700 mb-2 text-center">Tip-to-Time Automation Rules</label> | |
| <div class="flex items-center justify-center gap-2"> | |
| <input type="number" id="tip-token-input" placeholder="Tokens" class="w-24 p-2 text-center border-gray-300 rounded-lg"> | |
| <input type="number" id="tip-time-input" placeholder="Seconds" class="w-24 p-2 text-center border-gray-300 rounded-lg"> | |
| <button id="add-tip-button-btn" class="bg-blue-500 text-white font-bold py-2 px-4 rounded-lg">Add Rule</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- =================================================================== --> | |
| <!-- SECTION 3: ON-STREAM OVERLAY VIEW (What viewers see) --> | |
| <!-- =================================================================== --> | |
| <div id="overlay-view" class="hidden p-4"> | |
| <div class="w-full max-w-2xl mx-auto"> | |
| <div class="bg-black bg-opacity-50 rounded-full p-1 border-2 border-pink-300 shadow-lg"> | |
| <div class="relative w-full h-10 flex items-center justify-center"> | |
| <div id="tip-bar-progress" class="absolute top-0 left-0 h-full bg-gradient-to-r from-pink-400 to-purple-500 rounded-full tip-bar-progress" style="width: 0%;"></div> | |
| <div class="relative z-10 font-brand text-white text-lg tracking-wider text-shadow"> | |
| <span class="font-bold">BUNNY BONUS:</span> | |
| <span id="current-tips-display">0</span> / <span id="tip-goal-display">1000</span> TOKENS | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // --- APP STATE & CONFIG --- | |
| const APP_STATE_KEY = 'bunnyCoPilotState'; | |
| const TIP_PATTERN = /"m":"(\w+) has tipped (\d+) tokens!"/; // Updated for common bot format | |
| // --- DOM Elements --- | |
| const views = { | |
| setup: document.getElementById('setup-view'), | |
| controls: document.getElementById('control-panel-view'), | |
| overlay: document.getElementById('overlay-view'), | |
| }; | |
| const elements = { | |
| usernameInput: document.getElementById('username-input'), | |
| startButton: document.getElementById('start-button'), | |
| displayUsername: document.getElementById('display-username'), | |
| obsLinkDisplay: document.getElementById('obs-link-display'), | |
| timer: document.getElementById('group-timer-display'), | |
| status: document.getElementById('group-status-message'), | |
| minutesInput: document.getElementById('group-minutes'), | |
| startBtn: document.getElementById('group-start-btn'), | |
| stopBtn: document.getElementById('group-stop-btn'), | |
| buttonsContainer: document.getElementById('dynamic-tip-buttons-container'), | |
| tipGoalInput: document.getElementById('tip-goal-input'), | |
| setGoalBtn: document.getElementById('set-goal-btn'), | |
| currentTipsDisplay: document.getElementById('current-tips-display'), | |
| tipGoalDisplay: document.getElementById('tip-goal-display'), | |
| tipBarProgress: document.getElementById('tip-bar-progress'), | |
| soundSelect: document.getElementById('sound-select'), | |
| addRuleBtn: document.getElementById('add-tip-button-btn'), | |
| tokenInput: document.getElementById('tip-token-input'), | |
| timeInput: document.getElementById('tip-time-input'), | |
| }; | |
| // --- SOUNDS --- | |
| let sounds = {}; | |
| const initSounds = () => { | |
| sounds = { | |
| chime: new Tone.Synth({ oscillator: { type: "sine" }, envelope: { attack: 0.005, decay: 0.1, sustain: 0.3, release: 1 } }).toDestination(), | |
| coin: new Tone.Synth({ oscillator: { type: "square" }, envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.1 } }).toDestination(), | |
| powerup: new Tone.Synth({ oscillator: { type: "triangle" }, envelope: { attack: 0.01, decay: 0.2, sustain: 0.1, release: 0.2 } }).toDestination(), | |
| boop: new Tone.MembraneSynth().toDestination() | |
| }; | |
| }; | |
| const playSound = (soundName) => { | |
| if (soundName === 'none' || !sounds[soundName]) return; | |
| Tone.start().then(() => { | |
| if (soundName === 'chime') sounds.chime.triggerAttackRelease("C5", "8n"); | |
| if (soundName === 'coin') sounds.coin.triggerAttackRelease("E6", "16n"); | |
| if (soundName === 'powerup') { const now = Tone.now(); sounds.powerup.triggerAttackRelease("C4", "16n", now); sounds.powerup.triggerAttackRelease("G4", "16n", now + 0.1); sounds.powerup.triggerAttackRelease("C5", "16n", now + 0.2); } | |
| if (soundName === 'boop') sounds.boop.triggerAttackRelease("C2", "8n"); | |
| }); | |
| }; | |
| // --- STATE MANAGEMENT --- | |
| let state = { | |
| username: '', | |
| isTimerRunning: false, | |
| timeLeft: 0, | |
| tipGoal: 1000, | |
| currentTips: 0, | |
| automationRules: [], | |
| selectedSound: 'chime', | |
| }; | |
| let timerInterval = null; | |
| const saveState = () => localStorage.setItem(APP_STATE_KEY, JSON.stringify(state)); | |
| const loadState = () => Object.assign(state, JSON.parse(localStorage.getItem(APP_STATE_KEY))); | |
| // --- UI RENDERING --- | |
| const formatTime = (s) => `${Math.floor(s/60)}`.padStart(2,'0')+':'+`${s%60}`.padStart(2,'0'); | |
| function render() { | |
| // Render for both controls and overlay | |
| elements.timer.textContent = formatTime(state.timeLeft); | |
| elements.currentTipsDisplay.textContent = state.currentTips; | |
| elements.tipGoalDisplay.textContent = state.tipGoal; | |
| const percentage = Math.min(100, (state.currentTips / state.tipGoal) * 100); | |
| elements.tipBarProgress.style.width = `${percentage}%`; | |
| // Render for controls only | |
| elements.tipGoalInput.value = state.tipGoal; | |
| elements.soundSelect.value = state.selectedSound; | |
| elements.startBtn.classList.toggle('hidden', state.isTimerRunning); | |
| elements.stopBtn.classList.toggle('hidden', !state.isTimerRunning); | |
| elements.buttonsContainer.innerHTML = ''; | |
| if (state.automationRules.length === 0) { | |
| elements.buttonsContainer.innerHTML = '<p class="text-gray-500 text-sm">Create automation rules.</p>'; | |
| } else { | |
| state.automationRules.forEach(rule => { | |
| const button = document.createElement('button'); | |
| button.className = 'bg-gray-600 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded-lg shadow-md text-sm'; | |
| button.textContent = `${rule.tokens}t (+${formatTime(rule.seconds)})`; | |
| button.onclick = () => processTip(rule.tokens, true); // true for manual override | |
| elements.buttonsContainer.appendChild(button); | |
| }); | |
| } | |
| } | |
| // --- CORE LOGIC --- | |
| function processTip(tokens, isManual = false) { | |
| if (!isManual && !state.isTimerRunning) return; // Don't auto-add time if timer isn't running | |
| const rule = state.automationRules.find(r => r.tokens === tokens); | |
| if (!rule) return; | |
| state.timeLeft += rule.seconds; | |
| state.currentTips += tokens; | |
| playSound(state.selectedSound); | |
| saveState(); | |
| render(); // Immediately render for controls, event listener handles overlay | |
| } | |
| function startTimer() { | |
| if (state.isTimerRunning) return; | |
| const minutes = parseInt(elements.minutesInput.value, 10); | |
| if (isNaN(minutes) || minutes <= 0) return; | |
| state.isTimerRunning = true; | |
| state.timeLeft = minutes * 60; | |
| elements.status.textContent = "Timer is LIVE!"; | |
| clearInterval(timerInterval); | |
| timerInterval = setInterval(() => { | |
| state.timeLeft--; | |
| if (state.timeLeft <= 0) { | |
| state.timeLeft = 0; | |
| state.isTimerRunning = false; | |
| clearInterval(timerInterval); | |
| elements.status.textContent = "Time's up! Tip to add more!"; | |
| } | |
| saveState(); | |
| render(); | |
| }, 1000); | |
| saveState(); | |
| render(); | |
| } | |
| function stopTimer() { | |
| state.isTimerRunning = false; | |
| state.timeLeft = 0; | |
| state.currentTips = 0; // Reset progress | |
| clearInterval(timerInterval); | |
| elements.status.textContent = "Timer Reset. Ready to go!"; | |
| saveState(); | |
| render(); | |
| } | |
| // --- CHATURBATE CONNECTION --- | |
| async function watchChat() { | |
| console.log(`Connecting to ${state.username}'s chat...`); | |
| try { | |
| const api_url = `https://chaturbate.com/get_edge_host/?room=${state.username}&type=chat`; | |
| const response = await fetch(api_url).then(res => res.json()); | |
| if (!response.host) throw new Error("Could not find chat server."); | |
| const ws_url = `wss://${response.host}/`; | |
| const socket = new WebSocket(ws_url); | |
| socket.onopen = () => { | |
| console.log("Chat connection established."); | |
| socket.send(`{"method":"connect","data":{"user":"guest_obs-${Date.now()}","room":"${state.username}","password":""}}`); | |
| socket.send(`{"method":"joinroom","data":{"room":"${state.username}"}}`); | |
| elements.status.textContent = "Automation connected! Ready to go live."; | |
| }; | |
| socket.onmessage = (event) => { | |
| const msg = event.data; | |
| const match = TIP_PATTERN.exec(msg); | |
| if (match) { | |
| const tokens = parseInt(match[2], 10); | |
| console.log(`Tip detected: ${tokens} tokens`); | |
| processTip(tokens); | |
| } | |
| }; | |
| socket.onclose = () => { | |
| console.warn("Chat connection closed. Reconnecting in 5 seconds..."); | |
| elements.status.textContent = "Automation lost! Reconnecting..."; | |
| setTimeout(watchChat, 5000); | |
| }; | |
| socket.onerror = (err) => { | |
| console.error("Chat socket error:", err); | |
| socket.close(); | |
| }; | |
| } catch (error) { | |
| console.error("Failed to connect to chat:", error); | |
| elements.status.textContent = "Connection failed! Retrying..."; | |
| setTimeout(watchChat, 10000); | |
| } | |
| } | |
| // --- INITIALIZATION & ROUTING --- | |
| function main() { | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const view = urlParams.get('view'); | |
| const username = urlParams.get('room'); | |
| if (view === 'overlay' && username) { | |
| // --- OVERLAY VIEW --- | |
| document.body.style.backgroundColor = 'transparent'; | |
| views.setup.classList.add('hidden'); | |
| views.controls.classList.add('hidden'); | |
| views.overlay.classList.remove('hidden'); | |
| if(localStorage.getItem(APP_STATE_KEY)) loadState(); | |
| render(); | |
| window.addEventListener('storage', (event) => { | |
| if (event.key === APP_STATE_KEY) { | |
| loadState(); | |
| render(); | |
| } | |
| }); | |
| } else if (username) { | |
| // --- CONTROL PANEL VIEW --- | |
| state.username = username; | |
| views.setup.classList.add('hidden'); | |
| views.overlay.classList.add('hidden'); | |
| views.controls.classList.remove('hidden'); | |
| elements.displayUsername.textContent = state.username; | |
| const overlayUrl = `${window.location.origin}${window.location.pathname}?view=overlay&room=${state.username}`; | |
| elements.obsLinkDisplay.value = overlayUrl; | |
| initSounds(); // Initialize audio context on user interaction | |
| if(localStorage.getItem(APP_STATE_KEY)) loadState(); // Load previous session settings | |
| render(); | |
| watchChat(); | |
| // Setup event listeners for controls | |
| elements.startButton.onclick = () => initSounds(); | |
| elements.startBtn.onclick = startTimer; | |
| elements.stopBtn.onclick = stopTimer; | |
| elements.setGoalBtn.onclick = () => { state.tipGoal = parseInt(elements.tipGoalInput.value, 10); state.currentTips = 0; saveState(); render(); }; | |
| elements.soundSelect.onchange = (e) => { state.selectedSound = e.target.value; saveState(); }; | |
| elements.addRuleBtn.onclick = () => { | |
| const tokens = parseInt(elements.tokenInput.value, 10); | |
| const seconds = parseInt(elements.timeInput.value, 10); | |
| if (!isNaN(tokens) && !isNaN(seconds) && tokens > 0 && seconds > 0) { | |
| state.automationRules.push({ tokens, seconds }); | |
| state.automationRules.sort((a,b) => a.tokens - b.tokens); | |
| elements.tokenInput.value = ''; elements.timeInput.value = ''; | |
| saveState(); render(); | |
| } | |
| }; | |
| } else { | |
| // --- SETUP VIEW --- | |
| views.controls.classList.add('hidden'); | |
| views.overlay.classList.add('hidden'); | |
| views.setup.classList.remove('hidden'); | |
| elements.startButton.onclick = () => { | |
| const enteredUsername = elements.usernameInput.value.trim(); | |
| if (enteredUsername) { | |
| window.location.search = `?room=${enteredUsername}`; | |
| } | |
| }; | |
| } | |
| } | |