// Twitch Drops Miner Web Client // Socket.IO and API communication // Global state const state = { connected: false, channels: {}, campaigns: {}, settings: {}, currentDrop: null, countdownTimer: null, // Track the active countdown timer translations: {} // Store current translations }; // Initialize Socket.IO connection const socket = io({ transports: ['websocket', 'polling'], reconnection: true, reconnectionDelay: 1000, reconnectionDelayMax: 5000, reconnectionAttempts: Infinity }); // ==================== Socket.IO Event Handlers ==================== socket.on('connect', () => { console.log('Connected to server'); state.connected = true; const connText = state.translations.gui?.websocket?.connected || 'Connected'; document.getElementById('connection-indicator').textContent = '● ' + connText; document.getElementById('connection-indicator').className = 'connected'; }); socket.on('disconnect', () => { console.log('Disconnected from server'); state.connected = false; const disconnText = state.translations.gui?.websocket?.disconnected || 'Disconnected'; document.getElementById('connection-indicator').textContent = '● ' + disconnText; document.getElementById('connection-indicator').className = 'disconnected'; }); socket.on('initial_state', (data) => { console.log('Received initial state', data); if (data.status) updateStatus(data.status); if (data.channels) data.channels.forEach(ch => updateChannel(ch)); if (data.campaigns) data.campaigns.forEach(camp => addCampaign(camp)); if (data.console) data.console.forEach(line => addConsoleLineRaw(line)); if (data.settings) updateSettingsUI(data.settings); if (data.login) updateLoginStatus(data.login); if (data.manual_mode) updateManualModeUI(data.manual_mode); // Restore current drop progress if it exists if (data.current_drop) { updateDropProgress(data.current_drop); } else { clearDropProgress(); } }); socket.on('status_update', (data) => { updateStatus(data.status); }); socket.on('console_output', (data) => { addConsoleLine(data.message); }); socket.on('channel_add', (data) => { updateChannel(data); }); socket.on('channel_update', (data) => { updateChannel(data); }); socket.on('channel_remove', (data) => { removeChannel(data.id); }); socket.on('channels_clear', () => { clearChannels(); }); socket.on('channels_batch_update', (data) => { // Replace all channels atomically to prevent flickering state.channels = {}; data.channels.forEach(ch => { state.channels[ch.id] = ch; }); renderChannels(); }); socket.on('channel_watching', (data) => { setWatchingChannel(data.id); }); socket.on('channel_watching_clear', () => { clearWatchingChannel(); }); socket.on('drop_progress', (data) => { updateDropProgress(data); }); socket.on('drop_progress_stop', () => { clearDropProgress(); }); socket.on('campaign_add', (data) => { addCampaign(data); }); socket.on('inventory_clear', () => { clearInventory(); }); socket.on('inventory_batch_update', (data) => { // Replace all campaigns atomically to prevent flickering state.campaigns = {}; data.campaigns.forEach(camp => { state.campaigns[camp.id] = camp; }); renderInventory(); }); socket.on('drop_update', (data) => { updateDrop(data.campaign_id, data.drop); }); socket.on('login_required', () => { showLoginForm(); }); socket.on('oauth_code_required', (data) => { showOAuthCode(data.url, data.code); }); socket.on('login_status', (data) => { updateLoginStatus(data); }); socket.on('login_clear', (data) => { if (data.login) document.getElementById('username').value = ''; if (data.password) document.getElementById('password').value = ''; if (data.token) document.getElementById('2fa-token').value = ''; }); socket.on('settings_updated', (data) => { updateSettingsUI(data); }); socket.on('games_available', (data) => { state.availableGames = data.games; }); socket.on('theme_change', (data) => { if (data.dark_mode) { document.body.classList.add('dark-mode'); } else { document.body.classList.remove('dark-mode'); } }); socket.on('notification', (data) => { if ('Notification' in window && Notification.permission === 'granted') { new Notification(data.title, { body: data.message, icon: '/static/icon.png' }); } }); socket.on('attention_required', (data) => { if (data.sound) { // Play notification sound const audio = new Audio('/static/notification.mp3'); audio.play().catch(() => { }); } // Flash title flashTitle(); }); socket.on('manual_mode_update', (data) => { updateManualModeUI(data); }); socket.on('language_changed', (data) => { console.log('Language changed to:', data.language); fetchAndApplyTranslations(); }); // ==================== UI Update Functions ==================== function updateStatus(status) { document.getElementById('status-text').textContent = status; // Loading overlay disabled - UI remains responsive during backend operations // Backend now uses batch updates to prevent flickering } function addConsoleLine(message) { addConsoleLineRaw(message); } function addConsoleLineRaw(line) { const console = document.getElementById('console-output'); const div = document.createElement('div'); div.textContent = line; console.appendChild(div); // Auto-scroll to bottom console.scrollTop = console.scrollHeight; // Limit lines while (console.children.length > 1000) { console.removeChild(console.firstChild); } } function updateChannel(channelData) { state.channels[channelData.id] = channelData; renderChannels(); } function removeChannel(channelId) { delete state.channels[channelId]; renderChannels(); } function clearChannels() { state.channels = {}; renderChannels(); } function setWatchingChannel(channelId) { Object.values(state.channels).forEach(ch => ch.watching = false); if (state.channels[channelId]) { state.channels[channelId].watching = true; } renderChannels(); } function clearWatchingChannel() { Object.values(state.channels).forEach(ch => ch.watching = false); renderChannels(); } function renderChannels() { const container = document.getElementById('channels-list'); container.innerHTML = ''; const t = state.translations; const channels = Object.values(state.channels); if (channels.length === 0) { const emptyMsg = t.gui?.channels?.no_channels || 'No channels tracked yet...'; container.innerHTML = `

${emptyMsg}

`; return; } // Get the games to watch list from settings const gamesToWatch = state.settings.games_to_watch || []; const gamesToWatchSet = new Set(gamesToWatch); // Filter channels to only include those playing games in the watch list const filteredChannels = channels.filter(channel => { const gameName = channel.game; // Include channels if: they have a game AND it's in the watch list // OR if the watch list is empty (show all) return gamesToWatch.length === 0 || (gameName && gamesToWatchSet.has(gameName)); }); if (filteredChannels.length === 0) { const emptyMsg = t.gui?.channels?.no_channels_for_games || 'No channels found for selected games...'; container.innerHTML = `

${emptyMsg}

`; return; } // Group channels by game const gameGroups = {}; filteredChannels.forEach(channel => { const gameName = channel.game || 'No Game'; const gameId = channel.game_id || 'no-game'; const gameIcon = channel.game_icon; if (!gameGroups[gameId]) { gameGroups[gameId] = { name: gameName, icon: gameIcon, channels: [] }; } gameGroups[gameId].channels.push(channel); }); // Sort games: prioritize games with watching channels, then by total viewers const sortedGames = Object.entries(gameGroups).sort(([idA, groupA], [idB, groupB]) => { const hasWatchingA = groupA.channels.some(ch => ch.watching); const hasWatchingB = groupB.channels.some(ch => ch.watching); if (hasWatchingA !== hasWatchingB) return hasWatchingB ? 1 : -1; // Sum total viewers for each game const totalViewersA = groupA.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0); const totalViewersB = groupB.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0); return totalViewersB - totalViewersA; }); // Render each game group sortedGames.forEach(([gameId, group]) => { // Create game header const gameHeader = document.createElement('div'); gameHeader.className = 'game-group-header'; let iconHtml = ''; if (group.icon) { // Resize the box art to 40x53 (Twitch's standard small size) const iconUrl = group.icon.replace('{width}', '40').replace('{height}', '53'); iconHtml = `${group.name}`; } const channelCount = group.channels.length; const totalViewers = group.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0); const channelText = channelCount === 1 ? (t.gui?.channels?.channel_count || 'channel') : (t.gui?.channels?.channel_count_plural || 'channels'); const viewersText = t.gui?.channels?.viewers || 'viewers'; gameHeader.innerHTML = ` ${iconHtml}
${group.name}
${channelCount} ${channelText} • ${totalViewers.toLocaleString()} ${viewersText}
`; container.appendChild(gameHeader); // Sort channels within game: watching first, then online, then by viewers group.channels.sort((a, b) => { if (a.watching !== b.watching) return b.watching ? 1 : -1; if (a.online !== b.online) return b.online ? 1 : -1; return (b.viewers || 0) - (a.viewers || 0); }); // Render channels in this game group.channels.forEach(channel => { const div = document.createElement('div'); div.className = 'channel-item'; if (channel.watching) div.classList.add('watching'); if (channel.online) div.classList.add('online'); else div.classList.add('offline'); let badges = ''; if (channel.drops_enabled) badges += 'DROPS'; if (channel.acl_based) badges += 'ACL'; div.innerHTML = `
${channel.name} ${badges}
${channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline'} ${channel.watching ? ' • WATCHING' : ''}
`; div.onclick = () => selectChannel(channel.id); container.appendChild(div); }); }); } function updateDropProgress(data) { // Check if this is a new drop or if remaining seconds changed significantly const isNewDrop = !state.currentDrop || state.currentDrop.drop_id !== data.drop_id; // Store old remaining seconds before updating state const oldRemaining = state.currentDrop ? state.currentDrop.remaining_seconds : null; // Update state with new data state.currentDrop = data; document.getElementById('no-drop-message').style.display = 'none'; document.getElementById('drop-info').style.display = 'block'; document.getElementById('drop-name').textContent = data.drop_name; // Make campaign name clickable with link to Twitch const dropGameEl = document.getElementById('drop-game'); if (data.campaign_id) { const campaignUrl = `https://www.twitch.tv/drops/campaigns?dropID=${data.campaign_id}`; dropGameEl.innerHTML = `${data.campaign_name} (${data.game_name})`; } else { dropGameEl.textContent = `${data.campaign_name} (${data.game_name})`; } const progress = data.progress * 100; const fill = document.getElementById('progress-fill'); fill.style.width = `${progress}%`; fill.textContent = `${Math.round(progress)}%`; document.getElementById('progress-text').textContent = `${data.current_minutes} / ${data.required_minutes} minutes`; // Only reset the timer if it's a new drop or if backend time differs by more than 2 seconds // This prevents constant timer resets from periodic backend updates const shouldResetTimer = isNewDrop || oldRemaining === null || Math.abs(oldRemaining - data.remaining_seconds) > 2; if (shouldResetTimer) { // Cancel any existing countdown timer before starting a new one if (state.countdownTimer !== null) { clearTimeout(state.countdownTimer); state.countdownTimer = null; } // Start countdown with the new value from backend updateRemainingTime(data.remaining_seconds); } // Otherwise, let the existing timer continue counting down smoothly } function updateRemainingTime(seconds) { const minutes = Math.floor(seconds / 60); const secs = seconds % 60; document.getElementById('progress-time').textContent = `Time remaining: ${minutes}:${secs.toString().padStart(2, '0')}`; if (seconds > 0) { // Store the timer ID so we can cancel it if needed state.countdownTimer = setTimeout(() => updateRemainingTime(seconds - 1), 1000); } else { state.countdownTimer = null; } } function clearDropProgress() { state.currentDrop = null; // Cancel any active countdown timer if (state.countdownTimer !== null) { clearTimeout(state.countdownTimer); state.countdownTimer = null; } document.getElementById('no-drop-message').style.display = 'block'; document.getElementById('drop-info').style.display = 'none'; } function addCampaign(campaignData) { state.campaigns[campaignData.id] = campaignData; renderInventory(); } function clearInventory() { state.campaigns = {}; renderInventory(); } function updateDrop(campaignId, dropData) { if (state.campaigns[campaignId]) { const drops = state.campaigns[campaignId].drops; const index = drops.findIndex(d => d.id === dropData.id); if (index !== -1) { drops[index] = dropData; renderInventory(); } } } function renderInventory() { const container = document.getElementById('inventory-grid'); container.innerHTML = ''; const t = state.translations; const campaigns = Object.values(state.campaigns); if (campaigns.length === 0) { const emptyMsg = t.gui?.inventory?.no_campaigns || 'No campaigns loaded yet...'; container.innerHTML = `

${emptyMsg}

`; return; } campaigns.forEach(campaign => { const card = document.createElement('div'); card.className = 'campaign-card'; let statusClass = ''; let statusText = ''; if (campaign.active) { statusClass = 'active'; statusText = t.gui?.inventory?.status?.active || 'Active'; } else if (campaign.upcoming) { statusClass = 'upcoming'; statusText = t.gui?.inventory?.status?.upcoming || 'Upcoming'; } else if (campaign.expired) { statusClass = 'expired'; statusText = t.gui?.inventory?.status?.expired || 'Expired'; } const claimedText = t.gui?.inventory?.status?.claimed || 'Claimed'; const dropsHtml = campaign.drops.map(drop => `
${drop.name}
${drop.rewards}
${drop.current_minutes} / ${drop.required_minutes} minutes (${Math.round(drop.progress * 100)}%)
${drop.is_claimed ? `
✓ ${claimedText}
` : ''}
`).join(''); // Make campaign name clickable if link_url is available const campaignNameHtml = campaign.link_url ? `${campaign.name} 🔗` : `
${campaign.name}
`; const claimedCountText = t.gui?.inventory?.claimed_drops || 'claimed'; card.innerHTML = `
${campaign.game_name}
${campaignNameHtml}
${statusText} ${campaign.claimed_drops} / ${campaign.total_drops} ${claimedCountText}
${dropsHtml}
`; container.appendChild(card); }); } function showLoginForm() { document.getElementById('login-form').style.display = 'block'; document.getElementById('oauth-code-display').style.display = 'none'; } function showOAuthCode(url, code) { document.getElementById('login-form').style.display = 'none'; document.getElementById('oauth-code-display').style.display = 'block'; document.getElementById('oauth-url').href = url; document.getElementById('oauth-code').textContent = code; } function updateLoginStatus(data) { const statusEl = document.getElementById('login-status'); const t = state.translations; if (data.user_id) { const userIdLabel = t.gui?.login?.user_id_label || 'User ID:'; statusEl.textContent = `${data.status} (${userIdLabel} ${data.user_id})`; statusEl.removeAttribute('translation-key'); statusEl.style.color = 'var(--success-color)'; document.getElementById('login-form').style.display = 'none'; document.getElementById('oauth-code-display').style.display = 'none'; } else { const loggedOut = t.gui?.login?.logged_out || 'Not logged in'; statusEl.textContent = data.status || loggedOut; statusEl.setAttribute('translation-key', 'logged_out'); statusEl.style.color = 'var(--text-secondary)'; // Check if OAuth is pending (for late-connecting clients) if (data.oauth_pending) { showOAuthCode(data.oauth_pending.url, data.oauth_pending.code); } } } function updateSettingsUI(settings) { state.settings = settings; document.getElementById('dark-mode').checked = settings.dark_mode || false; document.getElementById('connection-quality').value = settings.connection_quality || 1; document.getElementById('minimum-refresh-interval').value = settings.minimum_refresh_interval_minutes || 30; // Update language dropdown if we have the current language if (settings.language) { const languageSelect = document.getElementById('language'); if (languageSelect) { languageSelect.value = settings.language; } } if (settings.dark_mode) { document.body.classList.add('dark-mode'); } else { document.body.classList.remove('dark-mode'); } // Update available games if provided in settings if (settings.games_available) { availableGames = new Set(settings.games_available); } // Update games to watch lists renderGamesToWatch(); // Re-render channels list to apply filter based on updated games to watch renderChannels(); } function updateManualModeUI(manualModeInfo) { const manualBadge = document.getElementById('manual-mode-badge'); const autoBadge = document.getElementById('auto-mode-badge'); const manualGameName = document.getElementById('manual-game-name'); const manualControls = document.getElementById('manual-mode-controls'); const manualModeGame = document.getElementById('manual-mode-game'); if (manualModeInfo.active) { // Show manual mode badge, hide auto badge manualBadge.classList.remove('hidden'); autoBadge.classList.add('hidden'); manualGameName.textContent = manualModeInfo.game_name || ''; // Show manual mode controls in drop progress section if (manualControls) { manualControls.classList.remove('hidden'); if (manualModeGame) { manualModeGame.textContent = manualModeInfo.game_name || ''; } } } else { // Hide manual mode badge, show auto badge manualBadge.classList.add('hidden'); autoBadge.classList.remove('hidden'); // Hide manual mode controls if (manualControls) { manualControls.classList.add('hidden'); } } } // ==================== Games to Watch Management ==================== let availableGames = new Set(); // All games from campaigns let draggedElement = null; socket.on('games_available', (data) => { availableGames = new Set(data.games || []); renderGamesToWatch(); }); function renderGamesToWatch() { const selectedGames = state.settings.games_to_watch || []; const filterText = document.getElementById('games-filter')?.value.toLowerCase() || ''; // Render selected games (sortable) renderSelectedGames(selectedGames); // Render available games (checkboxes for unselected games) const unselectedGames = Array.from(availableGames) .filter(game => !selectedGames.includes(game)) .filter(game => game.toLowerCase().includes(filterText)) .sort(); renderAvailableGames(unselectedGames, filterText); } function renderSelectedGames(games) { const container = document.getElementById('selected-games-list'); if (!container) return; const t = state.translations; container.innerHTML = ''; if (games.length === 0) { const emptyMsg = t.gui?.settings?.no_games_selected || 'No games selected. Check games below to add them.'; container.innerHTML = `

${emptyMsg}

`; return; } games.forEach((game, index) => { const div = document.createElement('div'); div.className = 'sortable-item'; div.draggable = true; div.dataset.game = game; div.innerHTML = ` ${index + 1} ${game} `; // Event listener for the delete button const removeBtn = div.querySelector('.remove-btn'); removeBtn.addEventListener('click', () => removeGameFromWatch(game)); // Drag event handlers div.addEventListener('dragstart', handleDragStart); div.addEventListener('dragover', handleDragOver); div.addEventListener('drop', handleDrop); div.addEventListener('dragend', handleDragEnd); container.appendChild(div); }); } function renderAvailableGames(games, filterText) { const container = document.getElementById('available-games-list'); if (!container) return; const t = state.translations; container.innerHTML = ''; if (games.length === 0) { if (filterText) { const emptyMsg = t.gui?.settings?.no_games_match || 'No games match your search.'; container.innerHTML = `

${emptyMsg}

`; } else { const emptyMsg = t.gui?.settings?.all_games_selected || 'All games are selected or no games available.'; container.innerHTML = `

${emptyMsg}

`; } return; } games.forEach(game => { const label = document.createElement('label'); label.className = 'game-checkbox'; label.innerHTML = ` ${game} `; const checkbox = label.querySelector('input[type="checkbox"]'); checkbox.addEventListener('change', (e) => toggleGameWatch(game, e.target.checked)); container.appendChild(label); }); } // Drag and drop handlers function handleDragStart(e) { draggedElement = e.target; e.target.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/html', e.target.innerHTML); } function handleDragOver(e) { if (e.preventDefault) { e.preventDefault(); } e.dataTransfer.dropEffect = 'move'; const target = e.target.closest('.sortable-item'); if (target && target !== draggedElement) { const container = target.parentNode; const allItems = [...container.querySelectorAll('.sortable-item')]; const draggedIndex = allItems.indexOf(draggedElement); const targetIndex = allItems.indexOf(target); if (draggedIndex < targetIndex) { target.parentNode.insertBefore(draggedElement, target.nextSibling); } else { target.parentNode.insertBefore(draggedElement, target); } } return false; } function handleDrop(e) { if (e.stopPropagation) { e.stopPropagation(); } return false; } function handleDragEnd(e) { e.target.classList.remove('dragging'); // Update the order in state const container = document.getElementById('selected-games-list'); const items = container.querySelectorAll('.sortable-item'); const newOrder = Array.from(items).map(item => item.dataset.game); state.settings.games_to_watch = newOrder; // Re-render to update priority numbers renderSelectedGames(newOrder); // Re-render channels list to apply updated filter renderChannels(); // Save settings saveSettings(); } function toggleGameWatch(gameName, checked) { const games = state.settings.games_to_watch || []; if (checked && !games.includes(gameName)) { games.push(gameName); } else if (!checked) { const index = games.indexOf(gameName); if (index > -1) { games.splice(index, 1); } } state.settings.games_to_watch = games; renderGamesToWatch(); renderChannels(); saveSettings(); } function removeGameFromWatch(gameName) { const games = state.settings.games_to_watch || []; const index = games.indexOf(gameName); if (index > -1) { games.splice(index, 1); state.settings.games_to_watch = games; renderGamesToWatch(); renderChannels(); saveSettings(); } } function selectAllGames() { state.settings.games_to_watch = Array.from(availableGames).sort(); renderGamesToWatch(); renderChannels(); saveSettings(); } function deselectAllGames() { state.settings.games_to_watch = []; renderGamesToWatch(); renderChannels(); saveSettings(); } function flashTitle() { const originalTitle = document.title; let count = 0; const interval = setInterval(() => { document.title = count % 2 === 0 ? '🔔 Attention!' : originalTitle; count++; if (count >= 10) { document.title = originalTitle; clearInterval(interval); } }, 1000); } // ==================== API Functions ==================== async function selectChannel(channelId) { try { const response = await fetch('/api/channels/select', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ channel_id: channelId }) }); if (!response.ok) { const errorData = await response.json(); console.error('Failed to select channel:', errorData.detail || 'Unknown error'); addConsoleLine(`Error selecting channel: ${errorData.detail || 'Unknown error'}`); } } catch (error) { console.error('Failed to select channel:', error); addConsoleLine(`Error selecting channel: ${error.message}`); } } async function exitManualMode() { try { const response = await fetch('/api/mode/exit-manual', { method: 'POST' }); const result = await response.json(); if (!result.success) { console.log('Exit manual mode:', result.message || 'Already in automatic mode'); } } catch (error) { console.error('Failed to exit manual mode:', error); addConsoleLine(`Error exiting manual mode: ${error.message}`); } } async function submitLogin() { const username = document.getElementById('username').value; const password = document.getElementById('password').value; const token = document.getElementById('2fa-token').value; try { await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, token }) }); } catch (error) { console.error('Failed to submit login:', error); } } async function confirmOAuth() { // Signal that OAuth code has been entered try { await fetch('/api/oauth/confirm', { method: 'POST' }); // Hide the OAuth form and show waiting message document.getElementById('oauth-code-display').style.display = 'none'; const t = state.translations; const waitingAuth = t.gui?.login?.waiting_auth || 'Waiting for authentication...'; const loginStatus = document.getElementById('login-status'); loginStatus.textContent = waitingAuth; loginStatus.setAttribute('translation-key', 'waiting_auth'); } catch (error) { console.error('Failed to confirm OAuth:', error); } } async function saveSettings() { const settings = { dark_mode: document.getElementById('dark-mode').checked, language: document.getElementById('language').value, connection_quality: parseInt(document.getElementById('connection-quality').value), minimum_refresh_interval_minutes: parseInt(document.getElementById('minimum-refresh-interval').value), games_to_watch: state.settings.games_to_watch || [] }; try { await fetch('/api/settings', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); console.log('Settings saved automatically'); } catch (error) { console.error('Failed to save settings:', error); } } async function fetchAndPopulateLanguages() { try { const response = await fetch('/api/languages'); const data = await response.json(); const languageSelect = document.getElementById('language'); if (!languageSelect) { console.warn('Language select element not found'); return; } // Clear existing options languageSelect.innerHTML = ''; // Populate with available languages data.available.forEach(lang => { const option = document.createElement('option'); option.value = lang; option.textContent = lang; languageSelect.appendChild(option); }); // Set current language if (data.current) { languageSelect.value = data.current; } } catch (error) { console.error('Failed to fetch languages:', error); const languageSelect = document.getElementById('language'); if (languageSelect) { languageSelect.innerHTML = ''; } addConsoleLine('Error: Unable to fetch available languages. Please check your connection or try again later.'); } } async function fetchAndApplyTranslations() { try { const response = await fetch('/api/translations'); const data = await response.json(); state.translations = data; applyTranslations(data); console.log('Translations applied for language:', data.language_name); } catch (error) { console.error('Failed to fetch translations:', error); } } function applyTranslations(t) { // Update tab buttons const tabButtons = { 'main': document.querySelector('[data-tab="main"]'), 'inventory': document.querySelector('[data-tab="inventory"]'), 'settings': document.querySelector('[data-tab="settings"]'), 'help': document.querySelector('[data-tab="help"]') }; if (tabButtons.main && t.gui?.tabs) tabButtons.main.textContent = t.gui.tabs.main; if (tabButtons.inventory && t.gui?.tabs) tabButtons.inventory.textContent = t.gui.tabs.inventory; if (tabButtons.settings && t.gui?.tabs) tabButtons.settings.textContent = t.gui.tabs.settings; if (tabButtons.help && t.gui?.tabs) tabButtons.help.textContent = t.gui.tabs.help; // Update Main tab - Login section const mainTab = document.getElementById('main-tab'); if (mainTab && t.gui?.login) { const loginHeader = mainTab.querySelector('.login-panel h2'); if (loginHeader) loginHeader.textContent = t.gui.login.name; const loginStatus = document.getElementById('login-status'); if (loginStatus?.hasAttribute('translation-key')) loginStatus.textContent = t.login?.status?.[loginStatus.getAttribute('translation-key')]; // Update login form placeholders const usernameInput = document.getElementById('username'); if (usernameInput) usernameInput.placeholder = t.gui.login.username; const passwordInput = document.getElementById('password'); if (passwordInput) passwordInput.placeholder = t.gui.login.password; const twofaInput = document.getElementById('2fa-token'); if (twofaInput) twofaInput.placeholder = t.gui.login.twofa_code; const loginButton = document.getElementById('login-button'); if (loginButton) loginButton.textContent = t.gui.login.button; // Update OAuth display text const oauthDisplay = document.getElementById('oauth-code-display'); if (oauthDisplay) { const oauthP = oauthDisplay.querySelector('p'); if (oauthP) { const link = oauthP.querySelector('a'); if (link) { oauthP.textContent = t.gui.login.oauth_prompt + ' '; link.textContent = t.gui.login.oauth_activate; oauthP.appendChild(link); } } const oauthConfirmBtn = document.getElementById('oauth-confirm'); if (oauthConfirmBtn) oauthConfirmBtn.textContent = t.gui.login.oauth_confirm; } } // Update Progress section if (mainTab && t.gui?.progress) { const progressHeader = mainTab.querySelector('.progress-panel h2'); if (progressHeader) progressHeader.textContent = t.gui.progress.name; const noDropMsg = document.getElementById('no-drop-message'); if (noDropMsg) noDropMsg.textContent = t.gui.progress.no_drop; const exitManualBtn = document.getElementById('exit-manual-btn'); if (exitManualBtn) exitManualBtn.textContent = t.gui.progress.return_to_auto; } // Update Console section if (mainTab && t.gui) { const consoleHeader = mainTab.querySelector('.console-panel h2'); if (consoleHeader) consoleHeader.textContent = t.gui.output; } // Update Channels section if (mainTab && t.gui?.channels) { const channelsHeader = mainTab.querySelector('.channels-panel h2'); if (channelsHeader) channelsHeader.textContent = t.gui.channels.name; // Channel list will re-render with translated empty messages renderChannels(); } // Update Inventory tab const inventoryTab = document.getElementById('inventory-tab'); if (inventoryTab && t.gui?.inventory) { // Inventory will re-render with translated status and empty messages renderInventory(); } // Update Settings tab const settingsTab = document.getElementById('settings-tab'); if (settingsTab && t.gui?.settings) { const headers = settingsTab.querySelectorAll('h2'); if (headers[0]) headers[0].textContent = t.gui.settings.general; if (headers[1]) headers[1].textContent = t.gui.settings.games_to_watch; if (headers[2]) headers[2].textContent = t.gui.settings.actions; const darkModeLabel = settingsTab.querySelector('label:has(#dark-mode)'); if (darkModeLabel) { const checkbox = darkModeLabel.querySelector('input'); darkModeLabel.textContent = ''; darkModeLabel.appendChild(checkbox); darkModeLabel.appendChild(document.createTextNode(' ' + t.gui.settings.dark_mode)); } const connQualityLabel = settingsTab.querySelector('label:has(#connection-quality)'); if (connQualityLabel) { const input = connQualityLabel.querySelector('input'); connQualityLabel.textContent = t.gui.settings.connection_quality + ' '; connQualityLabel.appendChild(input); } const refreshLabel = settingsTab.querySelector('label:has(#minimum-refresh-interval)'); if (refreshLabel) { const input = refreshLabel.querySelector('input'); refreshLabel.textContent = t.gui.settings.minimum_refresh + ' '; refreshLabel.appendChild(input); } const helpText = settingsTab.querySelector('.help-text'); if (helpText) helpText.textContent = t.gui.settings.games_help; const searchInput = document.getElementById('games-filter'); if (searchInput) searchInput.placeholder = t.gui.settings.search_games; const selectAllBtn = document.getElementById('select-all-btn'); if (selectAllBtn) selectAllBtn.textContent = t.gui.settings.select_all; const deselectAllBtn = document.getElementById('deselect-all-btn'); if (deselectAllBtn) deselectAllBtn.textContent = t.gui.settings.deselect_all; const selectedGamesHeader = settingsTab.querySelector('.selected-games h3'); if (selectedGamesHeader) selectedGamesHeader.textContent = t.gui.settings.selected_games; const availableGamesHeader = settingsTab.querySelector('.available-games h3'); if (availableGamesHeader) availableGamesHeader.textContent = t.gui.settings.available_games; const reloadBtn = document.getElementById('reload-btn'); if (reloadBtn) reloadBtn.textContent = t.gui.settings.reload_campaigns; // Re-render games to watch with translated empty messages renderGamesToWatch(); } // Update Help tab const helpTab = document.getElementById('help-tab'); if (helpTab && t.gui?.help) { const helpContent = helpTab.querySelector('.help-content'); if (helpContent) { // Rebuild help content dynamically helpContent.innerHTML = `

${t.gui.help.about || 'About Twitch Drops Miner'}

${t.gui.help.about_text || 'This application automatically mines timed Twitch drops without downloading stream data.'}

${t.gui.help.how_to_use || 'How to Use'}

    ${(t.gui.help.how_to_use_items || [ 'Login using your Twitch account (OAuth device code flow)', 'Link your accounts at twitch.tv/drops/campaigns', 'The miner will automatically discover campaigns and start mining', 'Configure priority games in Settings to focus on what you want', 'Monitor progress in the Main and Inventory tabs' ]).map(item => `
  1. ${item}
  2. `).join('')}

${t.gui.help.features || 'Features'}

${t.gui.help.important_notes || 'Important Notes'}

`; } } // Update header elements if (t.gui?.header) { const languageLabel = document.querySelector('.language-selector span'); if (languageLabel) languageLabel.textContent = t.gui.header.language; const statusText = document.getElementById('status-text'); if (statusText && statusText.textContent === 'Initializing...') { statusText.textContent = t.gui.header.initializing; } // Update connection indicator const connIndicator = document.getElementById('connection-indicator'); if (connIndicator) { if (state.connected) { connIndicator.textContent = '● ' + (t.gui.websocket.connected || 'Connected'); } else { connIndicator.textContent = '● ' + (t.gui.websocket.disconnected || 'Disconnected'); } } } } async function reloadCampaigns() { try { await fetch('/api/reload', { method: 'POST' }); // Status will update via Socket.IO when backend starts operation } catch (error) { console.error('Failed to reload:', error); } } // ==================== Tab Management ==================== function switchTab(tabName) { // Hide all tabs document.querySelectorAll('.tab-content').forEach(tab => { tab.classList.remove('active'); }); document.querySelectorAll('.tab-button').forEach(btn => { btn.classList.remove('active'); }); // Show selected tab document.getElementById(`${tabName}-tab`).classList.add('active'); document.querySelector(`[data-tab="${tabName}"]`).classList.add('active'); } // ==================== Event Listeners ==================== document.addEventListener('DOMContentLoaded', () => { // Tab switching document.querySelectorAll('.tab-button').forEach(button => { button.addEventListener('click', () => { switchTab(button.dataset.tab); }); }); // Login form document.getElementById('login-button').addEventListener('click', submitLogin); document.getElementById('oauth-confirm').addEventListener('click', confirmOAuth); // Settings - auto-save on change document.getElementById('dark-mode').addEventListener('change', (e) => { // Apply dark mode immediately for instant feedback if (e.target.checked) { document.body.classList.add('dark-mode'); } else { document.body.classList.remove('dark-mode'); } // Then save settings saveSettings(); }); document.getElementById('language').addEventListener('change', saveSettings); document.getElementById('connection-quality').addEventListener('change', saveSettings); document.getElementById('minimum-refresh-interval').addEventListener('change', saveSettings); document.getElementById('reload-btn').addEventListener('click', reloadCampaigns); // Games to watch management document.getElementById('select-all-btn').addEventListener('click', selectAllGames); document.getElementById('deselect-all-btn').addEventListener('click', deselectAllGames); document.getElementById('games-filter').addEventListener('input', renderGamesToWatch); // Manual mode controls const exitManualBtn = document.getElementById('exit-manual-btn'); if (exitManualBtn) { exitManualBtn.addEventListener('click', exitManualMode); } // Fetch and populate available languages fetchAndPopulateLanguages(); // Fetch and apply translations for the current language fetchAndApplyTranslations(); // Request notification permission if ('Notification' in window && Notification.permission === 'default') { Notification.requestPermission(); } });