// Constants for favicon management const FAVICON_STATES = { INITIAL: 'initial', CUSTOM: 'custom', EMOJI: 'emoji', COLOR: 'color', RESTORING: 'restoring' }; const FAVICON_PRIORITIES = { RESTORE: 3, USER_ACTION: 2, ANIMATION: 1, DEFAULT: 0 }; // Constants for state management const STATE_KEYS = { SEQUENCE: 'tabtitle_sequence_state', FAVICON: 'tabtitle_favicon_state', USER_PREFS: 'tabtitle_user_prefs', VERSION: 'tabtitle_state_version' }; const CURRENT_STATE_VERSION = '1.0'; // State Manager for persistence class StateManager { constructor() { this.version = CURRENT_STATE_VERSION; this.initialized = false; this.storageAvailable = this._checkStorageAvailability(); } _checkStorageAvailability() { try { const storage = window.localStorage; const testKey = '__storage_test__'; storage.setItem(testKey, testKey); storage.removeItem(testKey); return true; } catch (e) { return false; } } initialize() { if (!this.storageAvailable) { console.warn('LocalStorage is not available. State persistence will be disabled.'); return; } // Check version and handle migrations if needed const savedVersion = localStorage.getItem(STATE_KEYS.VERSION); if (savedVersion !== this.version) { this._handleVersionMigration(savedVersion); } this.initialized = true; } _handleVersionMigration(oldVersion) { // Handle state migrations between versions // For now, just clear old state and set new version this.clearAll(); localStorage.setItem(STATE_KEYS.VERSION, this.version); } saveSequenceState(state) { if (!this.initialized || !this.storageAvailable) return; try { const serializedState = JSON.stringify({ currentIndex: state.currentIndex, isRunning: state.isRunning, startTime: state.startTime, currentItemStartTime: state.currentItemStartTime, delayElapsed: state.delayElapsed, remainingDelay: state.remainingDelay, sequenceMetadata: state.sequenceMetadata, timestamp: Date.now() }); localStorage.setItem(STATE_KEYS.SEQUENCE, serializedState); } catch (error) { console.warn('Failed to save sequence state:', error); } } loadSequenceState() { if (!this.initialized || !this.storageAvailable) return null; try { const savedState = localStorage.getItem(STATE_KEYS.SEQUENCE); if (!savedState) return null; const state = JSON.parse(savedState); // Only return state if it's less than 30 minutes old if (Date.now() - state.timestamp < 30 * 60 * 1000) { return { ...state, sequenceMetadata: state.sequenceMetadata || { groupId: null, type: null, totalItems: 0, completedItems: 0, lastCompletedTimestamp: null } }; } // Clear old state localStorage.removeItem(STATE_KEYS.SEQUENCE); return null; } catch (error) { console.warn('Failed to load sequence state:', error); return null; } } saveFaviconState(state) { if (!this.initialized || !this.storageAvailable) return; try { const serializedState = JSON.stringify({ currentFavicon: state.currentFavicon, isUpdating: state.isUpdating, original: state.original, customActive: state.customActive, // Add customActive flag to saved state timestamp: Date.now() }); localStorage.setItem(STATE_KEYS.FAVICON, serializedState); } catch (error) { console.warn('Failed to save favicon state:', error); } } loadFaviconState() { if (!this.initialized || !this.storageAvailable) return null; try { const savedState = localStorage.getItem(STATE_KEYS.FAVICON); if (!savedState) return null; const state = JSON.parse(savedState); // Only return state if it's less than 30 minutes old if (Date.now() - state.timestamp < 30 * 60 * 1000) { return state; } // Clear old state localStorage.removeItem(STATE_KEYS.FAVICON); return null; } catch (error) { console.warn('Failed to load favicon state:', error); return null; } } saveUserPreferences(prefs) { if (!this.initialized || !this.storageAvailable) return; try { localStorage.setItem(STATE_KEYS.USER_PREFS, JSON.stringify(prefs)); } catch (error) { console.warn('Failed to save user preferences:', error); } } loadUserPreferences() { if (!this.initialized || !this.storageAvailable) return null; try { const savedPrefs = localStorage.getItem(STATE_KEYS.USER_PREFS); return savedPrefs ? JSON.parse(savedPrefs) : null; } catch (error) { console.warn('Failed to load user preferences:', error); return null; } } clearAll() { if (!this.storageAvailable) return; Object.values(STATE_KEYS).forEach(key => { localStorage.removeItem(key); }); } } // Sequence types and manager class SequenceManager { constructor() { this.currentSequence = []; this.currentIndex = 0; this.state = { isRunning: false, startTime: 0, currentItemStartTime: 0, delayElapsed: 0, remainingDelay: 0, sequenceMetadata: { groupId: null, type: null, totalItems: 0, completedItems: 0, lastCompletedTimestamp: null, currentCycle: 0 } }; this.timeouts = { delay: null, duration: null }; this.defaultDelay = 0; this.retryAttempts = 0; this.maxRetries = 3; this.debug = true; this.lastError = null; } _log(message, data = null) { if (this.debug) { console.log(`[SequenceManager] ${message}`, data || ''); } } start(sequence, metadata = {}) { this._log('Starting sequence with metadata', metadata); this.stop(); this.currentSequence = sequence; this.currentIndex = 0; this.lastError = null; this.state = { ...this.state, isRunning: true, startTime: Date.now(), currentItemStartTime: Date.now(), delayElapsed: 0, remainingDelay: 0, sequenceMetadata: { groupId: metadata.groupId || null, type: this._determineSequenceType(sequence), totalItems: sequence.length, completedItems: 0, lastCompletedTimestamp: null, currentCycle: 0 } }; this.retryAttempts = 0; if (this.currentSequence.length > 0) { this.startCurrentItem(); } } _determineSequenceType(sequence) { if (!sequence || sequence.length === 0) return null; const types = new Set(sequence.map(item => item.type)); if (types.size === 1) { return Array.from(types)[0]; // 'message' or 'favicon' } return 'mixed'; } stop() { this.state.isRunning = false; this.clearTimeouts(); this.retryAttempts = 0; } pause() { if (this.state.isRunning) { this.state.isRunning = false; this.state.delayElapsed = Date.now() - this.state.currentItemStartTime; this.state.remainingDelay = this._calculateRemainingDelay(); this.clearTimeouts(); } } resume() { if (!this.state.isRunning && this.currentSequence.length > 0) { this.state.isRunning = true; this.state.currentItemStartTime = Date.now() - this.state.delayElapsed; // Calculate remaining delay based on when we paused const remainingDelay = this._calculateRemainingDelay(); this.startCurrentItem(remainingDelay); } } _calculateRemainingDelay() { const currentItem = this.getCurrentItem(); if (!currentItem) return 0; const itemDelay = currentItem.delay || this.defaultDelay; const elapsedTime = Date.now() - this.state.currentItemStartTime; return Math.max(0, itemDelay - elapsedTime); } clearTimeouts() { Object.values(this.timeouts).forEach(timeout => { if (timeout) { clearTimeout(timeout); } }); this.timeouts = { delay: null, duration: null }; } startCurrentItem(remainingDelay = 0) { const item = this.getCurrentItem(); if (!item) return; const delay = remainingDelay > 0 ? remainingDelay : (item.delay || this.defaultDelay); if (delay > 0) { this.timeouts.delay = setTimeout(() => { this.executeItem(item); }, delay); } else { this.executeItem(item); } } executeItem(item) { if (!this.state.isRunning) return; this._log('Executing sequence item', { item, currentIndex: this.currentIndex, timestamp: new Date(), cycle: this.state.sequenceMetadata.currentCycle }); try { // Clear any existing timeouts first this.clearTimeouts(); // Set duration timeout if specified if (item.duration > 0) { this.timeouts.duration = setTimeout(() => { this._log('Item duration completed', { item, duration: item.duration }); this.moveToNextItem(); }, item.duration); } // Update sequence metadata this.state.sequenceMetadata.completedItems++; // Notify handler if (this.onItemStart) { this.onItemStart(item); } // Reset retry counter on successful execution this.retryAttempts = 0; this.lastError = null; } catch (error) { this.lastError = error; this._log('Error executing item', error); this._handleExecutionError(item); } } _handleExecutionError(item) { if (this.retryAttempts < this.maxRetries) { this.retryAttempts++; this._log(`Retrying sequence item execution`, { attempt: this.retryAttempts, maxRetries: this.maxRetries, item, error: this.lastError }); // Store current state before retry const currentState = { ...this.state }; setTimeout(() => { // Restore state before retrying this.state = currentState; this.executeItem(item); }, 1000); } else { this._log('Max retry attempts reached', { item, error: this.lastError }); this.moveToNextItem(); } } moveToNextItem() { if (!this.state.isRunning) return; this._log('Moving to next item', { currentIndex: this.currentIndex, nextIndex: (this.currentIndex + 1) % this.currentSequence.length }); this.currentIndex = (this.currentIndex + 1) % this.currentSequence.length; this.state.currentItemStartTime = Date.now(); this.state.delayElapsed = 0; this.clearTimeouts(); if (this.currentIndex === 0) { // Completed one full cycle this.state.sequenceMetadata.currentCycle++; this.state.sequenceMetadata.lastCompletedTimestamp = Date.now(); if (this.onSequenceComplete) { this.onSequenceComplete(this.state.sequenceMetadata); } } this.startCurrentItem(); } getCurrentItem() { return this.currentSequence[this.currentIndex]; } setItemStartHandler(handler) { this.onItemStart = handler; } setSequenceCompleteHandler(handler) { this.onSequenceComplete = handler; } getState() { return { ...this.state, currentIndex: this.currentIndex, currentSequence: this.currentSequence, sequenceMetadata: this.state.sequenceMetadata }; } setState(state) { if (!state) return; this._log('Setting state', state); this.state = { isRunning: state.isRunning || false, startTime: state.startTime || 0, currentItemStartTime: state.currentItemStartTime || 0, delayElapsed: state.delayElapsed || 0, remainingDelay: state.remainingDelay || 0, sequenceMetadata: state.sequenceMetadata || { groupId: null, type: null, totalItems: 0, completedItems: 0, lastCompletedTimestamp: null } }; this.currentIndex = state.currentIndex || 0; if (state.currentSequence) { this.currentSequence = state.currentSequence; } } } class FaviconManager { constructor() { this.state = { isUpdating: false, currentFavicon: null, original: null, customActive: false }; this.element = null; this.observer = null; this.initialized = false; this.debug = true; } _log(message, data = null) { if (this.debug) { console.log(`[FaviconManager] ${message}`, data ? JSON.stringify(data, null, 2) : ''); } } initialize() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { this._log('DOMContentLoaded event fired'); setTimeout(() => this._init(), 100); }); } else { this._log('Document already loaded'); setTimeout(() => this._init(), 100); } window.addEventListener('load', () => { this._log('Window load event fired'); setTimeout(() => { const currentFavicon = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]'); if (currentFavicon && currentFavicon.href) { this._log('Updating original favicon after load', { href: currentFavicon.href }); this.state.original = { url: currentFavicon.href, element: currentFavicon.cloneNode(true) }; this.state.currentFavicon = this.state.original.url; } else { this._log('No favicon found after load'); } }, 200); }); } _init() { this._log('Initializing FaviconManager'); this.element = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]'); if (!this.element) { this._log('No existing favicon found, creating new element'); this.element = document.createElement('link'); this.element.rel = 'icon'; document.head.appendChild(this.element); } else { this._log('Found existing favicon element', { href: this.element.href }); } this.state.original = { url: this.element.href, element: this.element.cloneNode(true) }; this.state.currentFavicon = this.state.original.url; this._log('Stored original favicon state', this.state.original); this.observer = new MutationObserver((mutations) => { if (!this.state.isUpdating) { for (const mutation of mutations) { if (mutation.type === 'attributes' && mutation.attributeName === 'href' && mutation.target === this.element) { const newHref = this.element.href; if (newHref !== this.state.currentFavicon) { this._log('External favicon change detected', { newHref, currentFavicon: this.state.currentFavicon, isCustomActive: this.state.customActive }); if (!document.hidden) { this.state.original = { url: newHref, element: this.element.cloneNode(true) }; } } } } } }); this.observer.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ['href'] }); this.initialized = true; this._log('FaviconManager initialized', { state: this.state }); } setFavicon(url) { if (!this.initialized || !url) { this._log('Cannot set favicon - not initialized or no URL provided', { initialized: this.initialized, url }); return; } this._log('Setting favicon', { url, previousFavicon: this.state.currentFavicon, isCustomActive: this.state.customActive }); this.state.isUpdating = true; this.state.currentFavicon = url; this.state.customActive = true; if (this.element) { this.element.href = url; this._log('Favicon href updated successfully'); } else { this._log('Error: Favicon element not found'); } setTimeout(() => { this.state.isUpdating = false; this._log('Reset updating flag'); }, 100); } restore() { if (!this.initialized || !this.state.original) { this._log('Cannot restore favicon - not initialized or no original state', { initialized: this.initialized, hasOriginal: !!this.state.original }); return; } this._log('Restoring original favicon', { originalUrl: this.state.original.url, currentFavicon: this.state.currentFavicon, isCustomActive: this.state.customActive }); if (this.element) { this.element.remove(); this._log('Removed current favicon element'); } this.element = this.state.original.element.cloneNode(true); document.head.appendChild(this.element); this.state.currentFavicon = this.state.original.url; this.state.customActive = false; this.state.isUpdating = false; this._log('Original favicon restored successfully', { url: this.state.currentFavicon }); } } class TabTitle { constructor(apiKey) { if (!apiKey) { console.error("TabTitle: API key is required"); return; } this.apiKey = apiKey; this.isPageVisible = !document.hidden; this.animator = null; this.sequences = []; this.messages = []; this.lastState = null; this.faviconRules = []; this.currentFaviconRule = null; this.faviconInterval = null; this.faviconTimeout = null; // Initialize managers this.stateManager = new StateManager(); this.stateManager.initialize(); this.faviconManager = new FaviconManager(); this.faviconManager.initialize(); this.sequenceManager = new SequenceManager(); this.sequenceManager.setItemStartHandler(this._handleSequenceItem.bind(this)); this.sequenceManager.setSequenceCompleteHandler(this._handleSequenceComplete.bind(this)); // Restore saved states this._restoreSavedStates(); // Set up title observer before initializing const titleObserver = new MutationObserver(() => { if (!this.originalTitle) { this.originalTitle = document.title; this.animator = new TabTitleAnimator(this.originalTitle); } }); // Observe title changes titleObserver.observe(document.querySelector('title') || document.head, { subtree: true, characterData: true, childList: true }); // Capture initial title this.originalTitle = document.title; this.animator = new TabTitleAnimator(this.originalTitle); document.addEventListener( "visibilitychange", this._handleVisibilityChange.bind(this) ); // Set up periodic state saving setInterval(() => { this._saveCurrentStates(); }, 5000); // Save states every 5 seconds this._fetchMessages(); this._fetchFaviconRules(); setInterval(() => { this._fetchMessages(); this._fetchFaviconRules(); }, 5 * 60 * 1000); } _restoreSavedStates() { // Restore sequence state const sequenceState = this.stateManager.loadSequenceState(); if (sequenceState && this.sequenceManager) { this.sequenceManager.setState(sequenceState); if (sequenceState.isRunning && !this.isPageVisible) { // Resume sequence if it was running this.sequenceManager.resume(); } } // Restore favicon state const faviconState = this.stateManager.loadFaviconState(); if (faviconState && this.faviconManager) { this.faviconManager.state = faviconState; if (faviconState.currentFavicon && !this.isPageVisible) { this.faviconManager.setFavicon(faviconState.currentFavicon); } } // Restore user preferences const userPrefs = this.stateManager.loadUserPreferences(); if (userPrefs) { // Apply user preferences (animation speeds, delays, etc.) this._applyUserPreferences(userPrefs); } } _saveCurrentStates() { if (this.sequenceManager) { this.stateManager.saveSequenceState(this.sequenceManager.getState()); } if (this.faviconManager) { this.stateManager.saveFaviconState(this.faviconManager.state); } } _applyUserPreferences(prefs) { // Apply saved user preferences if (prefs.animationSpeed) { this.animator?.setDefaultSpeed(prefs.animationSpeed); } if (prefs.defaultDelay) { this.sequenceManager.defaultDelay = prefs.defaultDelay; } // Add more preference applications as needed } _handleVisibilityChange() { const wasVisible = this.isPageVisible; this.isPageVisible = !document.hidden; console.log('Visibility changed:', { wasVisible, isNowVisible: this.isPageVisible, hasSequences: this.sequences.length > 0, hasFaviconRules: this.faviconRules.length > 0 }); if (document.hidden) { // Page is now hidden - start animations console.log('Page hidden, starting animations'); // Stop any existing animations first to ensure clean state this.sequenceManager.stop(); this._stopTitleChange(); // Start title animations if (this.sequences.length > 0) { const activeSequence = this.sequences.find(seq => this._checkSequenceConditions(seq)); if (activeSequence) { this.sequenceManager.start(activeSequence.items, activeSequence.metadata); } else { this._startTitleChange(); } } else { this._startTitleChange(); } // Start favicon animations if no custom favicon is active if (!this.faviconManager.state.customActive) { this._startFaviconChange(); } } else { // Page is now visible - stop animations and restore original state console.log('Page visible, stopping animations'); // Stop all animations this.sequenceManager.stop(); this._stopTitleChange(); // Restore original title and favicon document.title = this.originalTitle; this.faviconManager.restore(); this.faviconManager.state.customActive = false; } } _applyFaviconConfig(rule) { console.log('Applying favicon config:', rule); if (!rule || !rule.favicon_config) { console.warn('Invalid favicon config:', rule); return; } const config = rule.favicon_config; if (!config.type) { console.warn('Missing favicon type:', config); return; } try { if (config.type === 'custom' && config.customIconUrl) { console.log('Setting custom favicon:', config.customIconUrl); this.faviconManager.setFavicon(config.customIconUrl); } else if (config.type === 'emoji' && config.emoji) { console.log('Creating emoji favicon:', config.emoji); const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; const ctx = canvas.getContext('2d'); if (!ctx) { console.error('Failed to get canvas context'); return; } // Clear canvas with transparency instead of white background ctx.clearRect(0, 0, 32, 32); // Draw emoji ctx.font = '28px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; try { ctx.fillText(config.emoji, 16, 16); const faviconUrl = canvas.toDataURL('image/png'); console.log('Generated emoji favicon URL:', faviconUrl.substring(0, 100) + '...'); this.faviconManager.setFavicon(faviconUrl); } catch (err) { console.error('Error drawing emoji:', err); } } else if (config.type === 'color' && config.colors && config.colors.length > 0) { console.log('Setting up color favicon with colors:', config.colors); let colorIndex = 0; // Set initial color this._setColorFavicon(config.colors[0]); // Clear any existing interval if (this.faviconInterval) { clearInterval(this.faviconInterval); } // Set up color animation if we have multiple colors and timing if (config.colors.length > 1 && config.timing) { console.log('Starting color animation with timing:', config.timing); this.faviconInterval = setInterval(() => { colorIndex = (colorIndex + 1) % config.colors.length; this._setColorFavicon(config.colors[colorIndex]); }, config.timing); } } else { console.warn('Unsupported favicon type or missing required properties:', config); } } catch (err) { console.error('Error applying favicon config:', err); } } _setColorFavicon(color) { console.log('Setting color favicon:', color); const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; const ctx = canvas.getContext('2d'); if (!ctx) { console.error('Failed to get canvas context for color favicon'); return; } ctx.fillStyle = color; ctx.fillRect(0, 0, 32, 32); try { const faviconUrl = canvas.toDataURL('image/png'); console.log('Generated color favicon URL:', faviconUrl.substring(0, 100) + '...'); this.faviconManager.setFavicon(faviconUrl); } catch (err) { console.error('Error creating color favicon:', err); } } _getFaviconUrl() { const favicon = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]'); return favicon ? favicon.href : null; } _setFavicon(url) { if (!url) return; this.faviconManager.setFavicon(url, { delay: 0, cacheBust: true }); } _restoreOriginalFavicon() { if (this.faviconManager.state.original) { this.faviconManager.restore(); } } _createColorFavicon(color) { const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; const ctx = canvas.getContext('2d'); ctx.fillStyle = color; ctx.fillRect(0, 0, 32, 32); return canvas.toDataURL(); } async _fetchFaviconRules() { try { const response = await fetch("https://tabtitle.io/api/favicon-rules", { headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", }, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); if (!Array.isArray(data.rules)) { throw new Error("Invalid rules format received"); } this.faviconRules = data.rules; // Only apply rules if tab is inactive if (!this.isPageVisible) { this._applyFaviconRules(); } } catch (error) { console.error("TabTitle: Failed to fetch favicon rules:", error); } } _checkFaviconConditions(rule) { const currentPath = window.location.pathname; // Check path first, independent of schedule if (rule.path && rule.path !== '/') { if (!currentPath.startsWith(rule.path)) { return false; } } // If no schedule, we've already checked the path if (!rule.schedule) return true; const now = new Date(); // Check time range conditions if (rule.schedule.timeRanges?.length > 0) { const currentTime = now.getHours() * 60 + now.getMinutes(); const matchesTime = rule.schedule.timeRanges.some((range) => { const [start, end] = range.split("-"); const [startHour, startMinute] = start.split(":").map(Number); const [endHour, endMinute] = end.split(":").map(Number); const startTime = startHour * 60 + startMinute; const endTime = endHour * 60 + endMinute; return currentTime >= startTime && currentTime <= endTime; }); if (!matchesTime) return false; } // Check days of week conditions if (rule.schedule.daysOfWeek?.length > 0) { if (!rule.schedule.daysOfWeek.includes(now.getDay())) { return false; } } // Check date range conditions if (rule.schedule.dateRanges?.length > 0) { const currentDate = now.toISOString().split("T")[0]; const matchesDate = rule.schedule.dateRanges.some((range) => { const [start, end] = range.split("__"); return currentDate >= start && currentDate <= end; }); if (!matchesDate) return false; } return true; } _applyFaviconRules() { if (this.isPageVisible) return; // Clear any existing animation if (this.faviconInterval) { clearInterval(this.faviconInterval); this.faviconInterval = null; } if (this.faviconTimeout) { clearTimeout(this.faviconTimeout); this.faviconTimeout = null; } // Find ALL matching rules and sort by priority const matchingRules = this.faviconRules .filter(rule => this._checkFaviconConditions(rule)) .sort((a, b) => b.priority - a.priority); if (matchingRules.length === 0) { // Restore original favicon if no rules match if (this.faviconManager.state.original) { this._setFavicon(this.faviconManager.state.original.url); } return; } // Get the highest priority const highestPriority = matchingRules[0].priority; // Get all rules with the highest priority const highestPriorityRules = matchingRules.filter(rule => rule.priority === highestPriority); if (highestPriorityRules.length === 1) { // If only one rule, apply it normally this._applySingleFaviconRule(highestPriorityRules[0], this.faviconManager.state); } else { // If multiple rules with same priority, cycle between them this._cycleMultipleFaviconRules(highestPriorityRules, this.faviconManager.state); } } _applySingleFaviconRule(rule, state) { this.currentFaviconRule = rule; // Clear any existing timeouts and intervals if (this.faviconInterval) { clearInterval(this.faviconInterval); this.faviconInterval = null; } if (this.faviconTimeout) { clearTimeout(this.faviconTimeout); this.faviconTimeout = null; } // If there's a delay, keep the original favicon and set up the timeout if (rule.delay_time && rule.delay_time > 0) { state.isDelayActive = true; this._restoreOriginalFavicon(); // Keep original favicon during delay this.faviconTimeout = setTimeout(() => { if (!this.isPageVisible) { state.isDelayActive = false; this._applyFaviconConfig(rule); } }, rule.delay_time * 1000); } else { // No delay, apply immediately state.isDelayActive = false; this._applyFaviconConfig(rule); } } _cycleMultipleFaviconRules(rules, state) { let currentIndex = 0; let lastExecutionTime = 0; const scheduleNextRule = (isFirst = false) => { const rule = rules[currentIndex]; const nextIndex = (currentIndex + 1) % rules.length; // For first iteration, apply the initial delay if it exists const delay = isFirst && rule.delay_time ? rule.delay_time * 1000 : 0; // Calculate actual timing based on last execution const now = Date.now(); const timeSinceLastExecution = lastExecutionTime ? now - lastExecutionTime : 0; const adjustedDelay = Math.max(0, delay - timeSinceLastExecution); // Schedule the current rule this.faviconTimeout = setTimeout(() => { if (!this.isPageVisible) { this._applyFaviconConfig(rule); lastExecutionTime = Date.now(); // Schedule the next rule after the current rule's timing const timing = rule.favicon_config.timing || 1000; currentIndex = nextIndex; // Clear existing timeout before creating new one if (this.faviconTimeout) { clearTimeout(this.faviconTimeout); } // Schedule next rule with its timing (not delay, as delays only apply to first iteration) this.faviconTimeout = setTimeout(() => { scheduleNextRule(false); }, timing); } }, adjustedDelay); }; // Clear any existing timeouts and intervals if (this.faviconInterval) { clearInterval(this.faviconInterval); this.faviconInterval = null; } if (this.faviconTimeout) { clearTimeout(this.faviconTimeout); this.faviconTimeout = null; } // Start the sequence scheduleNextRule(true); } _startFaviconChange() { if (this.isPageVisible) return; console.log('Starting favicon change'); // Clear any existing animations first if (this.faviconInterval) { clearInterval(this.faviconInterval); this.faviconInterval = null; } if (this.faviconTimeout) { clearTimeout(this.faviconTimeout); this.faviconTimeout = null; } // Find matching rules const matchingRules = this.faviconRules .filter(rule => this._checkFaviconConditions(rule)) .sort((a, b) => b.priority - a.priority); if (matchingRules.length === 0) return; // Get highest priority rules const highestPriority = matchingRules[0].priority; const highestPriorityRules = matchingRules.filter(rule => rule.priority === highestPriority); // Set customActive flag before applying rules this.faviconManager.state.customActive = true; // Apply rules if (highestPriorityRules.length === 1) { this._applySingleFaviconRule(highestPriorityRules[0], this.faviconManager.state); } else { this._cycleMultipleFaviconRules(highestPriorityRules, this.faviconManager.state); } } _stopFaviconChange() { // Clear any animations if (this.faviconInterval) { clearInterval(this.faviconInterval); this.faviconInterval = null; } if (this.faviconTimeout) { clearTimeout(this.faviconTimeout); this.faviconTimeout = null; } // Update elapsed time if a delay was active if (this.faviconManager.state.isDelayActive && this.faviconManager.state.startTime) { this.faviconManager.state.delayElapsed = Date.now() - this.faviconManager.state.startTime; } // Always restore the original favicon when stopping this._restoreOriginalFavicon(); } async _fetchMessages() { try { const response = await fetch("https://tabtitle.io/api/messages", { headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json", }, }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); if (!Array.isArray(data.messages)) { throw new Error("Invalid message format received"); } // Process sequences and standalone messages const sequenceGroups = new Map(); const standalone = []; const processedIds = new Set(); // Track processed message IDs // First pass: Process and normalize all messages const normalizedMessages = data.messages.map(msg => ({ id: msg.id, ...msg, alternate_with_original: msg.alternate_with_original || false, delay: msg.delay_time ? msg.delay_time * 1000 : 0, duration: msg.duration || 0, animation_speed: parseInt(msg.animation_speed) || 5, animation_type: msg.animation_type || 'static' })); // Second pass: Separate sequences and standalone messages normalizedMessages.forEach((msg) => { if (processedIds.has(msg.id)) return; if (msg.sequence_id != null) { const group = sequenceGroups.get(msg.sequence_id) || []; group.push(msg); sequenceGroups.set(msg.sequence_id, group); processedIds.add(msg.id); } else { standalone.push(msg); processedIds.add(msg.id); } }); // Store messages but don't start animations yet this.messages = standalone; this.sequences = Array.from(sequenceGroups.entries()).map(([groupId, messages]) => { const sortedMessages = messages.sort((a, b) => (a.sequence_order || 0) - (b.sequence_order || 0)); return { items: sortedMessages.map(msg => ({ id: msg.id, type: 'message', content: { message: { content: msg.content, animation_type: msg.animation_type || 'static', animation_speed: msg.animation_speed || 5, alternate_with_original: msg.alternate_with_original || false } }, delay: msg.delay || 0, duration: msg.duration || 0 })), metadata: { groupId, originalStartTime: Date.now(), path: sortedMessages[0]?.path || null, conditions: sortedMessages[0]?.conditions || null, lastProcessedTime: Date.now(), totalItems: sortedMessages.length, currentCycle: 0 } }; }); // Only start animations if page is hidden if (document.hidden) { this._startTitleChange(); } } catch (error) { console.error("TabTitle: Failed to fetch messages:", error); } } _checkSequenceConditions(sequence) { if (!sequence.metadata.conditions && !sequence.metadata.path) { return true; } const currentPath = window.location.pathname; // Check path first if (sequence.metadata.path && !currentPath.startsWith(sequence.metadata.path)) { return false; } // If no conditions, path check was sufficient if (!sequence.metadata.conditions) { return true; } return this._checkConditions({ conditions: sequence.metadata.conditions }); } _stopTitleChange() { this.animator.stop(); } _checkConditions(message) { if (!message.conditions) return true; const now = new Date(); const currentPath = window.location.pathname; // Check path conditions if (message.conditions.paths?.length > 0) { const matchesPath = message.conditions.paths.some( (path) => currentPath === path || currentPath.startsWith(path) ); if (!matchesPath) return false; } // Check time range conditions if (message.conditions.timeRanges?.length > 0) { const currentTime = now.getHours() * 60 + now.getMinutes(); const matchesTime = message.conditions.timeRanges.some((range) => { const [start, end] = range.split("-"); const [startHour, startMinute] = start.split(":").map(Number); const [endHour, endMinute] = end.split(":").map(Number); const startTime = startHour * 60 + startMinute; const endTime = endHour * 60 + endMinute; return currentTime >= startTime && currentTime <= endTime; }); if (!matchesTime) return false; } // Check days of week conditions if (message.conditions.daysOfWeek?.length > 0) { if (!message.conditions.daysOfWeek.includes(now.getDay())) { return false; } } // Check date range conditions if (message.conditions.dateRanges?.length > 0) { const currentDate = now.toISOString().split("T")[0]; const matchesDate = message.conditions.dateRanges.some((range) => { const [start, end] = range.split("__"); return currentDate >= start && currentDate <= end; }); if (!matchesDate) return false; } return true; } _startTitleChange() { if (!document.hidden) { console.log('Page is visible, not starting title change'); return; } // If we have an active sequence, don't start regular messages if (this.sequenceManager.state.isRunning) { console.log('Sequence is running, not starting title change'); return; } console.log('Starting title change'); const validMessages = this.messages .filter((msg) => this._checkConditions(msg)) .map((msg) => ({ content: msg.content, animation_type: msg.animation_type || 'static', animation_speed: parseInt(msg.animation_speed) || 5, alternate_with_original: msg.alternate_with_original || false, duration: msg.duration || 0, delay: msg.delay_time ? msg.delay_time * 1000 : 0, priority: msg.priority || 0 })); if (validMessages.length > 0) { this.animator.setMessages(validMessages, null); } } _handleSequenceItem(item) { if (!item) return; // Only check if item exists, remove visibility check if (item.type === 'message' && item.content.message) { const message = { content: item.content.message.content, animation_type: item.content.message.animation_type || 'static', animation_speed: item.content.message.animation_speed || 5, alternate_with_original: item.content.message.alternate_with_original || false, delay: 0, // Already handled by SequenceManager duration: item.duration || 0 }; this.animator.setMessages([message], null); } else if (item.type === 'favicon' && item.content.favicon) { // Create the favicon config object with the correct structure const faviconConfig = { type: item.content.favicon.type, colors: item.content.favicon.type === 'color' ? [item.content.favicon.value] : undefined, emoji: item.content.favicon.type === 'emoji' ? item.content.favicon.value : undefined, customIconUrl: item.content.favicon.type === 'custom' ? item.content.favicon.value : undefined, timing: item.content.favicon.timing || 1000 }; this._applyFaviconConfig({ favicon_config: faviconConfig }); } } _handleSequenceComplete(metadata) { console.log('Sequence completed:', metadata); if (this.isPageVisible) { this._stopTitleChange(); this._stopFaviconChange(); } } _processSequenceData(data) { const sequenceItems = []; const metadata = { groupId: data.sequence_id || null, originalStartTime: Date.now() }; // Process messages if (data.message?.enabled && data.message.content) { sequenceItems.push({ type: 'message', content: { message: { content: data.message.content, animation_type: data.message.animation_type || 'static', animation_speed: data.message.animation_speed || 5 } }, delay: data.delay || 0, duration: data.duration || 0 }); } // Process favicon if (data.favicon?.enabled) { sequenceItems.push({ type: 'favicon', content: { favicon: { type: data.favicon.type, value: data.favicon.value, timing: data.favicon.timing || 1000 } }, delay: data.delay || 0, duration: data.duration || 0 }); } return { items: sequenceItems, metadata }; } _removeFavicon() { try { const existingFavicons = document.querySelectorAll('link[rel="icon"], link[rel="shortcut icon"]'); existingFavicons.forEach(favicon => { if (favicon && favicon.parentNode) { try { favicon.parentNode.removeChild(favicon); } catch (e) { console.warn('Error removing favicon:', e); } } }); } catch (e) { console.warn('Error in _removeFavicon:', e); } } } // TabTitleAnimator class implementation class TabTitleAnimator { constructor(originalTitle) { this.originalTitle = originalTitle; this.messages = []; this.currentMessageIndex = 0; this.typingIndex = 0; this.typingDirection = 'forward'; this.lastUpdateTime = Date.now(); this.messageStartTime = Date.now(); this.messageDisplayTime = Date.now(); this.messageDisplayDuration = 3000; // Default display duration for messages this.durationTimeout = null; this.animationInterval = null; this.delayTimeout = null; this.displayTimeout = null; this.alternateTimeout = null; this.isAnimating = false; this.isShowingOriginal = false; this.debug = true; // Add event listener for page visibility changes document.addEventListener('visibilitychange', this.handleVisibilityChange.bind(this)); } _log(message, data = null) { if (this.debug) { console.log(`[TabTitleAnimator] ${message}`, data || ''); } } // Add visibility change handler handleVisibilityChange() { if (!document.hidden) { // Reset title when page becomes visible this.stop(); } } clearTimeouts() { if (this.durationTimeout) clearTimeout(this.durationTimeout); if (this.delayTimeout) clearTimeout(this.delayTimeout); if (this.displayTimeout) clearTimeout(this.displayTimeout); if (this.alternateTimeout) clearTimeout(this.alternateTimeout); if (this.animationInterval) clearInterval(this.animationInterval); this.durationTimeout = null; this.delayTimeout = null; this.displayTimeout = null; this.alternateTimeout = null; this.animationInterval = null; } stop() { this._log('Stopping all animations'); this.clearTimeouts(); this.isAnimating = false; this.isShowingOriginal = false; this.messages = []; this.currentMessageIndex = 0; this.typingIndex = 0; document.title = this.originalTitle; } calculateUpdateInterval(message) { if (!message) return 100; // Default fallback switch (message.animation_type) { case 'scrolling': // Faster speed = smaller interval (faster updates) return Math.max(50, 500 - (message.animation_speed * 45)); case 'typing': // Faster speed = smaller interval (faster updates) return Math.max(40, 400 - (message.animation_speed * 35)); default: return 0; // No interval needed for static messages } } setMessages(messages, state) { this._log('Setting messages', { messagesCount: messages.length, state }); // Clear any existing animations first this.stop(); this.messages = [...messages].sort((a, b) => b.priority - a.priority); this.currentMessageIndex = 0; this.typingIndex = 0; this.typingDirection = 'forward'; this.isShowingOriginal = false; this.messageStartTime = Date.now(); if (this.messages.length > 0) { this.startNextMessage(); } } startNextMessage() { const message = this.messages[this.currentMessageIndex]; if (!message) { this.stop(); return; } this._log('Starting next message', { message }); // If message has a delay, start with delay if (message.delay && message.delay > 0) { this.startMessageWithDelay(message, message.delay); } else { this.startMessageAnimation(message); } } startMessageWithDelay(message, delay) { this._log('Starting message with delay', { message, delay }); // Clear any existing timeouts first this.clearTimeouts(); this.delayTimeout = setTimeout(() => { this.startMessageAnimation(message); }, delay); } startMessageAnimation(message) { this._log('Starting message animation', { message }); // Clear any existing animations first this.clearTimeouts(); this.isAnimating = true; this.messageDisplayTime = Date.now(); // Set the initial title document.title = message.content; // Set up animation interval if needed if (message.animation_type !== 'static') { const updateInterval = this.calculateUpdateInterval(message); if (updateInterval > 0) { this.animationInterval = setInterval(() => this.animate(), updateInterval); } } // Set up alternating if needed if (message.alternate_with_original) { this.setupAlternating(message); } // Set up duration timeout if specified if (message.duration > 0) { this.durationTimeout = setTimeout(() => { this._log('Message duration completed', { message }); this.switchToNextMessage(); }, message.duration * 1000); } } setupAlternating(message) { const alternateDuration = 3000; // 3 seconds for each phase if (this.alternateTimeout) { clearTimeout(this.alternateTimeout); } this.alternateTimeout = setTimeout(() => { this.isShowingOriginal = !this.isShowingOriginal; if (this.isShowingOriginal) { document.title = this.originalTitle; } else { document.title = message.content; } // Setup next alternation this.setupAlternating(message); }, alternateDuration); } animate() { const now = Date.now(); const message = this.messages[this.currentMessageIndex]; if (!message || !this.isAnimating) { document.title = this.originalTitle; return; } // Skip animation if we're showing original title due to alternating if (message.alternate_with_original && this.isShowingOriginal) { return; } // Check if it's time to switch to next message if (message.animation_type !== 'typing') { const displayTime = message.animation_type === 'scrolling' ? this.messageDisplayDuration * 2 : this.messageDisplayDuration; if (now - this.messageDisplayTime >= displayTime) { this.switchToNextMessage(); return; } } switch (message.animation_type) { case 'static': document.title = message.content; break; case 'scrolling': this.handleScrollingAnimation(message, now); break; case 'typing': this.handleTypingAnimation(message, now); break; } this.lastUpdateTime = now; } switchToNextMessage() { // Clear existing timeouts and intervals if (this.delayTimeout) clearTimeout(this.delayTimeout); if (this.displayTimeout) clearTimeout(this.displayTimeout); if (this.alternateTimeout) clearTimeout(this.alternateTimeout); if (this.animationInterval) clearInterval(this.animationInterval); this.currentMessageIndex = (this.currentMessageIndex + 1) % this.messages.length; this.typingIndex = 0; this.typingDirection = 'forward'; this.isShowingOriginal = false; this.startNextMessage(); } handleScrollingAnimation(message, now) { // Simplified scrolling logic that relies on the main interval for speed const content = message.content + ' ' + message.content; const position = Math.floor((now / (600 - message.animation_speed * 50)) % message.content.length); document.title = content.substring(position, position + message.content.length); this.lastUpdateTime = now; } handleTypingAnimation(message, now) { // Typing animation now uses the main interval for speed control if (this.typingDirection === 'forward') { if (this.typingIndex <= message.content.length) { document.title = message.content.substring(0, this.typingIndex) + '▌'; this.typingIndex++; if (this.typingIndex > message.content.length) { this.typingDirection = 'backward'; setTimeout(() => { if (this.typingDirection === 'backward') { this.lastUpdateTime = now; } }, 1000); } } } else { if (this.typingIndex >= 0) { document.title = message.content.substring(0, this.typingIndex) + '▌'; this.typingIndex--; if (this.typingIndex < 0) { this.typingDirection = 'forward'; this.switchToNextMessage(); } } } } getCurrentMessage() { return this.messages[this.currentMessageIndex]; } } window.TabTitle = TabTitle;