Add manual mode feature with UI improvements and batch updates

This commit introduces a manual mode for channel selection that allows users to lock onto a specific game, along with significant UI/UX improvements:

Manual Mode Features:
- Locks channel selection to a specific game when user manually selects a different game
- Automatically exits manual mode when all drops for the game are completed
- Shows visual indicators (🎯 badge) in status bar and drop display
- Allows manual exit via "Return to Auto Mode" button
- Prioritizes manual game in wanted_games list during auto-selection

UI/UX Improvements:
- Batch update system for channels and campaigns prevents flickering during refresh
- Channels list now updates atomically instead of clearing and re-adding
- Campaign loading emits all campaigns at once for smooth initial load
- Drop progress persists on initial_state load for reconnecting clients
- Game icons (box art) now displayed in channel list
- Campaign links added to inventory items

Bug Fixes:
- Fixed logging level to respect INFO by default (was hardcoded to DEBUG)
- Fixed channel cleanup to not call channel.remove() (handled by batch_update)
- Fixed drop display not persisting during channel switch
- Fixed stop_watching() clearing drop timer prematurely
- Fixed status display typo ("fames_to_watch" → "games_to_watch")

Backend Changes:
- Added manual mode tracking (_manual_target_channel, _manual_target_game)
- Added enter_manual_mode(), exit_manual_mode(), is_manual_mode() methods
- Added get_manual_mode_info() for API serialization
- Added batch_update() to ChannelListManager for atomic updates
- Added start_batch()/finalize_batch() to InventoryManager
- Added /api/mode/exit-manual endpoint
- Game model now stores box_art_url from GraphQL response
- Manual mode info included in /api/status and initial_state

Frontend Changes:
- Added manual mode badge and controls in UI
- Added channels_batch_update and inventory_batch_update socket events
- Added manual_mode_update socket event
- Countdown timer now properly tracked and cleared
- Drop display shows manual mode status with game name
- Exit manual mode button added to drop display area

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Fengqing Liu
2025-10-18 00:22:34 +11:00
parent 323c2ac40d
commit 68118d9e4b
11 changed files with 759 additions and 63 deletions

View File

@@ -13,6 +13,12 @@
<h1>🎮 Twitch Drops Miner</h1>
<div class="status-bar">
<span id="status-text">Initializing...</span>
<span id="manual-mode-badge" class="mode-badge hidden" title="Manual mode active - watching specific game">
🎯 MANUAL: <span id="manual-game-name"></span>
</span>
<span id="auto-mode-badge" class="mode-badge" title="Automatic mode - following game priority">
🤖 AUTO
</span>
<span id="connection-indicator" class="connected">● Connected</span>
</div>
</header>
@@ -50,6 +56,12 @@
<div id="drop-display">
<div id="no-drop-message">No active drop</div>
<div id="drop-info" style="display: none;">
<div id="manual-mode-controls" class="manual-mode-controls hidden">
<div class="manual-mode-info">
🎯 Manual Mode: Mining <strong id="manual-mode-game"></strong>
</div>
<button id="exit-manual-btn" class="exit-manual-btn">Return to Auto Mode</button>
</div>
<div class="drop-name" id="drop-name"></div>
<div class="drop-game" id="drop-game"></div>
<div class="progress-bar">

View File

@@ -7,7 +7,8 @@ const state = {
channels: {},
campaigns: {},
settings: {},
currentDrop: null
currentDrop: null,
countdownTimer: null // Track the active countdown timer
};
// Initialize Socket.IO connection
@@ -43,6 +44,13 @@ socket.on('initial_state', (data) => {
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) => {
@@ -69,6 +77,15 @@ 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);
});
@@ -93,6 +110,15 @@ 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);
});
@@ -150,10 +176,17 @@ socket.on('attention_required', (data) => {
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) {
@@ -211,45 +244,136 @@ function renderChannels() {
return;
}
// Sort: watching first, then online, then by viewers
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);
// 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));
});
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');
if (filteredChannels.length === 0) {
container.innerHTML = '<p class="empty-message">No channels found for selected games...</p>';
return;
}
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>';
// 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;
div.innerHTML = `
<div class="channel-name">${channel.name} ${badges}</div>
<div class="channel-info">
${channel.game || 'No game'}
${channel.viewers !== null ? channel.viewers.toLocaleString() + ' viewers' : 'Offline'}
${channel.watching ? ' • <strong>WATCHING</strong>' : ''}
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>
`;
div.onclick = () => selectChannel(channel.id);
container.appendChild(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;
document.getElementById('drop-game').textContent = `${data.campaign_name} (${data.game_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');
@@ -259,8 +383,21 @@ function updateDropProgress(data) {
document.getElementById('progress-text').textContent =
`${data.current_minutes} / ${data.required_minutes} minutes`;
// Update remaining time
updateRemainingTime(data.remaining_seconds);
// 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) {
@@ -270,12 +407,22 @@ function updateRemainingTime(seconds) {
`Time remaining: ${minutes}:${secs.toString().padStart(2, '0')}`;
if (seconds > 0) {
setTimeout(() => updateRemainingTime(seconds - 1), 1000);
// 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';
}
@@ -337,10 +484,15 @@ function renderInventory() {
</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>
<div class="campaign-name">${campaign.name}</div>
${campaignNameHtml}
</div>
<div class="campaign-status">
<span>${statusText}</span>
@@ -402,6 +554,41 @@ function updateSettingsUI(settings) {
// 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 ====================
@@ -539,6 +726,9 @@ function handleDragEnd(e) {
// Re-render to update priority numbers
renderSelectedGames(newOrder);
// Re-render channels list to apply updated filter
renderChannels();
// Save settings
saveSettings();
}
@@ -557,6 +747,7 @@ function toggleGameWatch(gameName, checked) {
state.settings.games_to_watch = games;
renderGamesToWatch();
renderChannels();
saveSettings();
}
@@ -567,6 +758,7 @@ function removeGameFromWatch(gameName) {
games.splice(index, 1);
state.settings.games_to_watch = games;
renderGamesToWatch();
renderChannels();
saveSettings();
}
}
@@ -574,12 +766,14 @@ function removeGameFromWatch(gameName) {
function selectAllGames() {
state.settings.games_to_watch = Array.from(availableGames).sort();
renderGamesToWatch();
renderChannels();
saveSettings();
}
function deselectAllGames() {
state.settings.games_to_watch = [];
renderGamesToWatch();
renderChannels();
saveSettings();
}
@@ -600,13 +794,36 @@ function flashTitle() {
async function selectChannel(channelId) {
try {
await fetch('/api/channels/select', {
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}`);
}
}
@@ -662,6 +879,7 @@ async function saveSettings() {
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);
}
@@ -717,6 +935,12 @@ document.addEventListener('DOMContentLoaded', () => {
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();

View File

@@ -207,6 +207,18 @@ header h1 {
margin-bottom: 15px;
}
.drop-campaign-link {
color: #0066cc;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
}
.drop-campaign-link:hover {
color: #0052a3;
text-decoration: underline;
}
.progress-bar {
width: 100%;
height: 30px;
@@ -260,9 +272,56 @@ header h1 {
overflow-y: auto;
}
/* Game Group Header */
.game-group-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
margin-top: 12px;
background: linear-gradient(135deg, var(--accent-color) 0%, rgba(145, 70, 255, 0.7) 100%);
border-radius: 8px;
color: white;
font-weight: bold;
position: sticky;
top: 0;
z-index: 10;
box-shadow: 0 2px 8px rgba(145, 70, 255, 0.3);
}
.game-group-header:first-child {
margin-top: 0;
}
.game-icon {
width: 40px;
height: 53px;
border-radius: 4px;
object-fit: cover;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
.game-group-info {
flex: 1;
}
.game-group-name {
font-size: 16px;
font-weight: bold;
margin-bottom: 2px;
}
.game-group-stats {
font-size: 12px;
opacity: 0.9;
font-weight: normal;
}
.channel-item {
padding: 12px;
margin-bottom: 8px;
margin-left: 16px;
background: var(--bg-secondary);
border: 2px solid var(--border-color);
border-radius: 6px;
@@ -346,6 +405,27 @@ header h1 {
color: var(--text-secondary);
}
.campaign-name-link {
font-size: 14px;
color: #0066cc;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 4px;
}
.campaign-name-link:hover {
color: #0052a3;
text-decoration: underline;
}
.external-link-icon {
font-size: 12px;
opacity: 0.7;
}
.campaign-status {
padding: 10px 15px;
font-size: 12px;
@@ -688,7 +768,131 @@ header h1 {
user-select: none;
}
/* Loading Overlay */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.loading-content {
background: var(--bg-panel);
border-radius: 12px;
padding: 40px;
text-align: center;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
}
.loading-spinner {
width: 50px;
height: 50px;
margin: 0 auto 20px;
border: 4px solid var(--border-color);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
#loading-message {
font-size: 16px;
color: var(--text-primary);
margin-top: 10px;
}
/* Manual Mode Styles */
.mode-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
letter-spacing: 0.5px;
margin-left: 10px;
transition: all 0.3s ease;
}
#auto-mode-badge {
background: rgba(145, 70, 255, 0.15);
color: var(--accent-color);
border: 1px solid var(--accent-color);
}
#manual-mode-badge {
background: rgba(255, 167, 38, 0.15);
color: var(--warning-color);
border: 1px solid var(--warning-color);
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.05);
opacity: 0.9;
}
}
.manual-mode-controls {
background: rgba(255, 167, 38, 0.1);
border: 2px solid var(--warning-color);
border-radius: 8px;
padding: 12px;
margin-bottom: 15px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
}
.manual-mode-info {
color: var(--text-primary);
font-size: 14px;
}
.manual-mode-info strong {
color: var(--warning-color);
}
.exit-manual-btn {
padding: 8px 16px;
background: var(--bg-panel);
color: var(--text-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: all 0.2s;
white-space: nowrap;
}
.exit-manual-btn:hover {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(145, 70, 255, 0.3);
}
/* Utilities */
.hidden {
display: none !important;
}
.empty-message {
text-align: center;
color: var(--text-secondary);