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.messages = []; this.sequences = []; this.wasAlternating = false; this.lastState = null; // 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) ); this._fetchMessages(); setInterval(() => this._fetchMessages(), 5 * 60 * 1000); } _handleVisibilityChange() { const wasVisible = this.isPageVisible; this.isPageVisible = !document.hidden; if (document.hidden) { if (this.animator) { // Save timing states this.lastState = { messageStartTime: this.animator.messageStartTime, alternatePhase: this.animator.alternatePhase }; } this._startTitleChange(); } else { this._stopTitleChange(); } } 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"); } const sequenceGroups = new Map(); const standalone = []; data.messages.forEach((msg) => { const messageObj = { ...msg, alternate_with_original: msg.alternate_with_original, delay: msg.delay_time ? msg.delay_time * 1000 : 0, animation_speed: parseInt(msg.animation_speed) || 5 }; if (msg.sequence_order != null) { const group = sequenceGroups.get(msg.sequence_order) || []; group.push(messageObj); sequenceGroups.set(msg.sequence_order, group); } else { standalone.push(messageObj); } }); this.sequences = Array.from(sequenceGroups.values()).map((group) => group.sort((a, b) => a.sequence_order - b.sequence_order) ); this.messages = standalone; if (!this.isPageVisible) { this._startTitleChange(); } } catch (error) { console.error("TabTitle: Failed to fetch messages:", error); } } _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 (this.isPageVisible) return; const validMessages = this.messages .filter((msg) => this._checkConditions(msg)) .map((msg) => { const preservedState = this.lastState || {}; return { ...msg, delayElapsed: preservedState.delayElapsed || 0, alternatePhase: preservedState.alternatePhase || 0, messageStartTime: preservedState.messageStartTime || Date.now() }; }); if (validMessages.length > 0) { this.animator.setMessages(validMessages, this.lastState); } } } // 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 = 5000; this.animationInterval = null; this.delayTimeout = null; this.displayTimeout = null; this.alternateTimeout = null; this.isAnimating = false; this.isShowingOriginal = false; } calculateUpdateInterval(message) { if (!message) return 100; // Default fallback switch (message.animation_type) { case 'static': return 1000; // Static messages update every second 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 100; } } setMessages(messages, state) { this.messages = [...messages].sort((a, b) => b.priority - a.priority); this.currentMessageIndex = 0; this.typingIndex = 0; this.typingDirection = 'forward'; // Clear any existing timeouts if (this.delayTimeout) clearTimeout(this.delayTimeout); if (this.displayTimeout) clearTimeout(this.displayTimeout); if (this.animationInterval) clearInterval(this.animationInterval); // Restore timing states if available if (state) { this.messageStartTime = state.messageStartTime; this.alternatePhase = state.alternatePhase || 0; // If there was a remaining delay, apply it if (state.remainingDelay > 0) { const currentMessage = this.messages[this.currentMessageIndex]; if (currentMessage) { this.startMessageWithDelay(currentMessage, state.remainingDelay); return; } } } else { this.messageStartTime = Date.now(); this.alternatePhase = 0; } this.startNextMessage(); } startMessageWithDelay(message, delay) { this.isAnimating = false; document.title = this.originalTitle; this.delayTimeout = setTimeout(() => { this.messageStartTime = Date.now(); this.startMessageAnimation(message); }, delay); } startNextMessage() { if (this.messages.length === 0) { document.title = this.originalTitle; return; } const message = this.messages[this.currentMessageIndex]; if (!message) { document.title = this.originalTitle; return; } if (message.delay > 0) { this.startMessageWithDelay(message, message.delay); } else { this.messageStartTime = Date.now(); this.startMessageAnimation(message); } } startMessageAnimation(message) { this.isAnimating = true; this.isShowingOriginal = false; this.messageDisplayTime = Date.now(); // Set up animation interval based on type const updateInterval = this.calculateUpdateInterval(message); if (this.animationInterval) clearInterval(this.animationInterval); this.animationInterval = setInterval(() => this.animate(), updateInterval); // If message should alternate, set up the alternating timeout if (message.alternate_with_original) { this.setupAlternating(message); } // Start the animation this.animate(); } 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); } stop() { if (this.delayTimeout) { clearTimeout(this.delayTimeout); this.delayTimeout = null; } if (this.displayTimeout) { clearTimeout(this.displayTimeout); this.displayTimeout = null; } if (this.alternateTimeout) { clearTimeout(this.alternateTimeout); this.alternateTimeout = null; } if (this.animationInterval) { clearInterval(this.animationInterval); this.animationInterval = null; } this.isAnimating = false; this.isShowingOriginal = false; document.title = this.originalTitle; } 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;