Files
TwitchDropsMiner/web/static/app.js
Fengqing Liu 472e6d73bd Add configurable minimum refresh interval setting
Added user-configurable minimum refresh interval for inventory reloads, allowing users to control how frequently the maintenance service triggers updates (default: 30 minutes, range: 1-1440 minutes).

- Add minimum_refresh_interval_minutes to settings system with default of 30 minutes
- Update maintenance service to use configurable interval instead of hardcoded 1-minute value
- Add GUI input field in General Settings with auto-save functionality
- Include field in API settings endpoint and settings manager

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-19 10:56:40 +11:00

952 lines
31 KiB
JavaScript

// 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
};
// 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;
document.getElementById('connection-indicator').textContent = '● Connected';
document.getElementById('connection-indicator').className = 'connected';
});
socket.on('disconnect', () => {
console.log('Disconnected from server');
state.connected = false;
document.getElementById('connection-indicator').textContent = '● Disconnected';
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);
});
// ==================== 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 channels = Object.values(state.channels);
if (channels.length === 0) {
container.innerHTML = '<p class="empty-message">No channels tracked yet...</p>';
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) {
container.innerHTML = '<p class="empty-message">No channels found for selected games...</p>';
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 = `<img src="${iconUrl}" alt="${group.name}" class="game-icon" onerror="this.style.display='none'">`;
}
const channelCount = group.channels.length;
const totalViewers = group.channels.reduce((sum, ch) => sum + (ch.viewers || 0), 0);
gameHeader.innerHTML = `
${iconHtml}
<div class="game-group-info">
<div class="game-group-name">${group.name}</div>
<div class="game-group-stats">${channelCount} channel${channelCount !== 1 ? 's' : ''}${totalViewers.toLocaleString()} viewers</div>
</div>
`;
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 += '<span class="channel-badge drops">DROPS</span>';
if (channel.acl_based) badges += '<span class="channel-badge acl">ACL</span>';
div.innerHTML = `
<div class="channel-name">${channel.name} ${badges}</div>
<div class="channel-info">
${channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline'}
${channel.watching ? ' • <strong>WATCHING</strong>' : ''}
</div>
`;
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 = `<a href="${campaignUrl}" target="_blank" rel="noopener noreferrer" class="drop-campaign-link">${data.campaign_name}</a> (${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 campaigns = Object.values(state.campaigns);
if (campaigns.length === 0) {
container.innerHTML = '<p class="empty-message">No campaigns loaded yet...</p>';
return;
}
campaigns.forEach(campaign => {
const card = document.createElement('div');
card.className = 'campaign-card';
let statusClass = '';
let statusText = '';
if (campaign.active) {
statusClass = 'active';
statusText = 'Active';
} else if (campaign.upcoming) {
statusClass = 'upcoming';
statusText = 'Upcoming';
} else if (campaign.expired) {
statusClass = 'expired';
statusText = 'Expired';
}
const dropsHtml = campaign.drops.map(drop => `
<div class="drop-item ${drop.is_claimed ? 'claimed' : ''} ${drop.can_claim ? 'active' : ''}">
<div><strong>${drop.name}</strong></div>
<div>${drop.rewards}</div>
<div>${drop.current_minutes} / ${drop.required_minutes} minutes (${Math.round(drop.progress * 100)}%)</div>
${drop.is_claimed ? '<div>✓ Claimed</div>' : ''}
</div>
`).join('');
// Make campaign name clickable if link_url is available
const campaignNameHtml = campaign.link_url
? `<a href="${campaign.link_url}" target="_blank" rel="noopener noreferrer" class="campaign-name-link">${campaign.name} <span class="external-link-icon">🔗</span></a>`
: `<div class="campaign-name">${campaign.name}</div>`;
card.innerHTML = `
<div class="campaign-header">
<div class="campaign-game">${campaign.game_name}</div>
${campaignNameHtml}
</div>
<div class="campaign-status">
<span>${statusText}</span>
<span>${campaign.claimed_drops} / ${campaign.total_drops} claimed</span>
</div>
<div class="campaign-drops">
${dropsHtml}
</div>
`;
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');
if (data.user_id) {
statusEl.textContent = `Logged in (User ID: ${data.user_id})`;
statusEl.style.color = 'var(--success-color)';
document.getElementById('login-form').style.display = 'none';
document.getElementById('oauth-code-display').style.display = 'none';
} else {
statusEl.textContent = data.status || 'Not logged in';
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;
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;
container.innerHTML = '';
if (games.length === 0) {
container.innerHTML = '<p class="empty-message">No games selected. Check games below to add them.</p>';
return;
}
games.forEach((game, index) => {
const div = document.createElement('div');
div.className = 'sortable-item';
div.draggable = true;
div.dataset.game = game;
div.innerHTML = `
<span class="drag-handle">☰</span>
<span class="priority-number">${index + 1}</span>
<span class="game-name">${game}</span>
<button class="remove-btn" onclick="removeGameFromWatch('${game}')">✕</button>
`;
// 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;
container.innerHTML = '';
if (games.length === 0) {
if (filterText) {
container.innerHTML = '<p class="empty-message">No games match your search.</p>';
} else {
container.innerHTML = '<p class="empty-message">All games are selected or no games available.</p>';
}
return;
}
games.forEach(game => {
const label = document.createElement('label');
label.className = 'game-checkbox';
label.innerHTML = `
<input type="checkbox" value="${game}" onchange="toggleGameWatch('${game}', this.checked)">
<span>${game}</span>
`;
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';
document.getElementById('login-status').textContent = 'Waiting for authentication...';
} catch (error) {
console.error('Failed to confirm OAuth:', error);
}
}
async function saveSettings() {
const settings = {
dark_mode: document.getElementById('dark-mode').checked,
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 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('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);
}
// Request notification permission
if ('Notification' in window && Notification.permission === 'default') {
Notification.requestPermission();
}
});