// 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
};
// ==================== Version Checking ====================
async function fetchAndDisplayVersion() {
try {
const response = await fetch('/api/version');
if (!response.ok) throw new Error('Failed to fetch version');
const data = await response.json();
const versionElement = document.getElementById('current-version');
if (versionElement) {
let versionText = data.current_version;
// Add (latest) indicator if we know the latest version and it matches
if (data.latest_version && data.current_version === data.latest_version) {
versionText += ' (latest)';
}
versionElement.textContent = versionText;
// Translate footer version text
const footerVersionText = document.getElementById('footer-version-text');
if (footerVersionText && state.translations.gui?.footer) {
const versionLabel = state.translations.gui.footer.version || 'Version:';
// Preserve the span inside
const span = footerVersionText.querySelector('span');
footerVersionText.textContent = versionLabel + ' ';
footerVersionText.appendChild(span);
}
}
// Display update notification if available
if (data.update_available && data.latest_version) {
const updateIndicator = document.getElementById('footer-update-indicator');
const latestVersionSpan = document.getElementById('latest-version');
const updateLink = document.getElementById('footer-update-link');
if (updateIndicator && latestVersionSpan && updateLink) {
latestVersionSpan.textContent = data.latest_version;
updateLink.href = data.download_url;
updateIndicator.style.display = 'inline-block';
// Translate update message
if (state.translations.gui?.footer) {
const updateLabel = state.translations.gui.footer.update_available || 'Update Available:';
const linkText = document.createTextNode(` ⚠ ${updateLabel} `);
// Clear existing text nodes but keep the span
const span = updateLink.querySelector('span'); // latest-version span
updateLink.textContent = '';
updateLink.appendChild(linkText);
updateLink.appendChild(span);
}
// Log to console
console.log(`Update available: ${data.latest_version} (current: ${data.current_version})`);
}
}
} catch (error) {
console.warn('Could not fetch version information:', error);
// Set placeholder text if fetch fails
const versionElement = document.getElementById('current-version');
const loadingText = state.translations.gui?.footer?.loading || 'Loading...';
if (versionElement && versionElement.textContent === loadingText) {
versionElement.textContent = 'Unknown';
}
}
}
// 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);
// Batch update channels to prevent UI freezing
if (data.channels) {
data.channels.forEach(ch => {
state.channels[ch.id] = ch;
});
renderChannels();
}
// Batch update campaigns to prevent UI freezing
if (data.campaigns) {
data.campaigns.forEach(camp => {
state.campaigns[camp.id] = camp;
});
renderInventory();
}
// Batch update console logs
if (data.console) {
const consoleEl = document.getElementById('console-output');
const fragment = document.createDocumentFragment();
data.console.forEach(line => {
const div = document.createElement('div');
div.textContent = line;
fragment.appendChild(div);
});
consoleEl.appendChild(fragment);
consoleEl.scrollTop = consoleEl.scrollHeight;
while (consoleEl.children.length > 1000) {
consoleEl.removeChild(consoleEl.firstChild);
}
}
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();
}
if (data.wanted_items) {
renderWantedItems(data.wanted_items);
}
});
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();
});
socket.on('wanted_items_update', (data) => {
renderWantedItems(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 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 = ``;
}
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}
`;
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();
}
}
}
// ==================== Inventory Filtering ====================
function getInventoryFilters() {
// Get filter state from UI checkboxes and selected games array
return {
show_active: document.getElementById('filter-active')?.checked || false,
show_not_linked: document.getElementById('filter-not-linked')?.checked || false,
show_upcoming: document.getElementById('filter-upcoming')?.checked || false,
show_expired: document.getElementById('filter-expired')?.checked || false,
show_finished: document.getElementById('filter-finished')?.checked || false,
game_name_search: [...selectedInventoryGames], // Array of selected game names
// Benefit type filters (default to true if checkbox doesn't exist)
show_benefit_item: document.getElementById('filter-benefit-item')?.checked !== false,
show_benefit_badge: document.getElementById('filter-benefit-badge')?.checked !== false,
show_benefit_emote: document.getElementById('filter-benefit-emote')?.checked !== false,
show_benefit_other: document.getElementById('filter-benefit-other')?.checked !== false
};
}
function campaignMatchesFilters(campaign, filters) {
// Calculate "finished" status: all drops claimed
const isFinished = campaign.total_drops > 0 && campaign.claimed_drops === campaign.total_drops;
// Check if any filter is enabled
const hasGameFilter = filters.game_name_search && filters.game_name_search.length > 0;
const anyFilterEnabled = filters.show_active || filters.show_not_linked ||
filters.show_upcoming || filters.show_expired ||
filters.show_finished || hasGameFilter;
// If no filters enabled, show all campaigns
if (!anyFilterEnabled) {
return true;
}
// Check status filters (OR logic - campaign matches if ANY checked filter applies)
let statusMatch = false;
if (filters.show_active && campaign.active) statusMatch = true;
if (filters.show_not_linked && !campaign.linked) statusMatch = true;
if (filters.show_upcoming && campaign.upcoming) statusMatch = true;
if (filters.show_expired && campaign.expired) statusMatch = true;
if (filters.show_finished && isFinished) statusMatch = true;
// If status filters are enabled but campaign doesn't match any, filter it out
const hasStatusFilters = filters.show_active || filters.show_not_linked ||
filters.show_upcoming || filters.show_expired ||
filters.show_finished;
if (hasStatusFilters && !statusMatch) {
return false;
}
// Check game name filter (AND logic with status filters, OR logic among selected games)
if (hasGameFilter) {
const gameName = campaign.game_name;
// Campaign must match at least ONE of the selected games
const gameMatch = filters.game_name_search.includes(gameName);
if (!gameMatch) {
return false;
}
}
// Check benefit type filter - campaign must have at least one drop with a matching benefit type
// Only filter if at least one benefit type is UNCHECKED (otherwise show all)
const allBenefitsEnabled = filters.show_benefit_item && filters.show_benefit_badge &&
filters.show_benefit_emote && filters.show_benefit_other;
if (!allBenefitsEnabled && campaign.drops) {
let benefitMatch = false;
for (const drop of campaign.drops) {
if (drop.benefits && drop.benefits.length > 0) {
for (const benefit of drop.benefits) {
const benefitType = (benefit.type || '').toUpperCase();
// Map filter checkboxes to actual API benefit types
if (filters.show_benefit_item && benefitType === 'DIRECT_ENTITLEMENT') benefitMatch = true;
if (filters.show_benefit_badge && benefitType === 'BADGE') benefitMatch = true;
if (filters.show_benefit_emote && benefitType === 'EMOTE') benefitMatch = true;
if (filters.show_benefit_other && benefitType === 'UNKNOWN') benefitMatch = true;
}
}
}
if (!benefitMatch) {
return false;
}
}
return true;
}
function onInventoryFilterChange() {
// Save filter state to settings and re-render inventory
saveSettings();
renderInventory();
}
function clearInventoryFilters() {
// Uncheck all filter checkboxes
document.getElementById('filter-active').checked = false;
document.getElementById('filter-not-linked').checked = false;
document.getElementById('filter-upcoming').checked = false;
document.getElementById('filter-expired').checked = false;
document.getElementById('filter-finished').checked = false;
document.getElementById('inventory-game-search').value = '';
// Reset benefit type filters to checked (show all)
if (document.getElementById('filter-benefit-item')) document.getElementById('filter-benefit-item').checked = true;
if (document.getElementById('filter-benefit-badge')) document.getElementById('filter-benefit-badge').checked = true;
if (document.getElementById('filter-benefit-emote')) document.getElementById('filter-benefit-emote').checked = true;
if (document.getElementById('filter-benefit-other')) document.getElementById('filter-benefit-other').checked = true;
// Clear selected games
selectedInventoryGames = [];
updateGameTagsDisplay();
// Save and re-render
saveSettings();
renderInventory();
}
// ==================== Game Dropdown & Tags ====================
// Track selected games for inventory filter
let selectedInventoryGames = [];
let gameDropdownFocusedIndex = -1;
let gameDropdownVisible = false;
function getAvailableGamesForDropdown() {
// Combine games from campaigns and availableGames Set
const gamesFromCampaigns = Object.values(state.campaigns).map(c => c.game_name);
const gamesFromSettings = Array.from(availableGames || []);
// Merge and deduplicate
const allGames = [...new Set([...gamesFromCampaigns, ...gamesFromSettings])];
// Sort alphabetically
return allGames.sort((a, b) => a.localeCompare(b));
}
function renderGameDropdown(searchTerm = '') {
const dropdown = document.getElementById('game-dropdown-list');
const allGames = getAvailableGamesForDropdown();
// Filter games by search term (case-insensitive)
const searchLower = searchTerm.toLowerCase().trim();
const filteredGames = searchLower
? allGames.filter(game => game.toLowerCase().includes(searchLower))
: allGames;
dropdown.innerHTML = '';
if (filteredGames.length === 0) {
dropdown.innerHTML = '
${(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 => `
${item}
`).join('')}
${t.gui.help.features || 'Features'}
${(t.gui.help.features_items || [
'Stream-less drop mining - saves bandwidth',
'Game priority and exclusion lists',
'Tracks up to 199 channels simultaneously',
'Automatic channel switching',
'Real-time progress tracking'
]).map(item => `
${(t.gui.help.important_notes_items || [
'Do not watch streams on the same account while mining',
'Keep your cookies.jar file secure',
'Requires linked game accounts for drops'
]).map(item => `