Created
August 6, 2025 05:48
-
-
Save hsingh23/13ff99906f3b384005701116968fcd17 to your computer and use it in GitHub Desktop.
Higgsfield Playlist Automation tampermonkey
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Higgsfield Playlist Automation | |
// @namespace http://tampermonkey.net/ | |
// @version 1.0.0 | |
// @description Automated playlist execution for Higgsfield.ai with throttling and queue management | |
// @author Auto-Generated | |
// @match https://higgsfield.ai/create/video* | |
// @require https://unpkg.com/[email protected]/dist/dexie.js | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=higgsfield.ai | |
// @grant none | |
// @run-at document-idle | |
// ==/UserScript== | |
(function() { | |
'use strict'; | |
// Configuration | |
const CONFIG = { | |
THROTTLE_MS: 2000, | |
MAX_IN_FLIGHT: 4, | |
MAX_RETRIES: 5, | |
DB_NAME: 'hfPromptDB', | |
DB_VERSION: 2, | |
DEBUG: true, | |
STORAGE_BROADCAST_KEY: 'hf_runner_active', | |
UI_Z_INDEX: 2147483647, | |
ENHANCEMENT_PREFIX: 'photorealistic, shot on Arri Alexa, 8k: ', | |
HASH_TIMEOUT_HOURS: 12 | |
}; | |
// Debug logging | |
const debug = (...args) => { | |
if (CONFIG.DEBUG) { | |
console.debug('[HF-Playlist]', ...args); | |
} | |
}; | |
// Hash utility for duplicate detection | |
async function generatePromptHash(prompt) { | |
const encoder = new TextEncoder(); | |
const data = encoder.encode(prompt.toLowerCase().trim()); | |
const hashBuffer = await crypto.subtle.digest('SHA-256', data); | |
const hashArray = Array.from(new Uint8Array(hashBuffer)); | |
return hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); | |
} | |
// Check if prompt was recently submitted | |
async function isPromptDuplicate(prompt) { | |
try { | |
// Skip check if database isn't available | |
if (!db || !db.prompt_hashes) { | |
return false; | |
} | |
const hash = await generatePromptHash(prompt); | |
const cutoffTime = Date.now() - (CONFIG.HASH_TIMEOUT_HOURS * 60 * 60 * 1000); | |
// Clean up old hashes (with limit to prevent blocking) | |
await db.prompt_hashes.where('timestamp').below(cutoffTime).limit(100).delete(); | |
// Check if hash exists | |
const existing = await db.prompt_hashes.where('hash').equals(hash).first(); | |
return existing !== undefined; | |
} catch (error) { | |
debug('Error checking prompt duplicate:', error); | |
return false; // If error, allow submission to be safe | |
} | |
} | |
// Store prompt hash | |
async function storePromptHash(prompt) { | |
try { | |
const hash = await generatePromptHash(prompt); | |
await db.prompt_hashes.add({ | |
hash, | |
timestamp: Date.now() | |
}); | |
debug('Stored prompt hash:', hash.substring(0, 8) + '...'); | |
} catch (error) { | |
debug('Error storing prompt hash:', error); | |
} | |
} | |
// Database setup | |
let db; | |
async function initDatabase() { | |
try { | |
db = new Dexie(CONFIG.DB_NAME); | |
// Version 1 - Original schema | |
db.version(1).stores({ | |
playlists: '++id, name, prompts, lastIndexSent, created, updated', | |
corrupt_playlists: '++id, name, data, error, created' | |
}); | |
// Version 2 - Added prompt hashes | |
db.version(2).stores({ | |
playlists: '++id, name, prompts, lastIndexSent, created, updated', | |
corrupt_playlists: '++id, name, data, error, created', | |
prompt_hashes: '++id, hash, timestamp' | |
}); | |
await db.open(); | |
debug('Database initialized, version:', db.verno); | |
// Clean up old hashes on startup | |
const cutoffTime = Date.now() - (CONFIG.HASH_TIMEOUT_HOURS * 60 * 60 * 1000); | |
await db.prompt_hashes?.where('timestamp').below(cutoffTime).delete(); | |
} catch (error) { | |
debug('Database error:', error); | |
showToast('Database initialization failed: ' + error.message, 'error'); | |
throw error; | |
} | |
} | |
// Playlist management | |
class PlaylistManager { | |
constructor() { | |
this.activePlaylists = new Map(); // id -> playlist data | |
this.runningState = 'stopped'; // 'running', 'paused', 'stopped' | |
this.inFlightPrompts = new Map(); | |
this.retryQueue = new Map(); | |
this.dispatchTimer = null; | |
this.lastDispatchTime = 0; | |
this.roundRobinIndex = 0; | |
} | |
async createPlaylist(name, promptsText) { | |
try { | |
const prompts = promptsText.split('~').map(p => p.trim()).filter(p => p); | |
const uniqueName = await this.generateUniqueName(name); | |
const playlist = { | |
name: uniqueName, | |
prompts, | |
lastIndexSent: 0, | |
created: new Date(), | |
updated: new Date() | |
}; | |
const id = await db.playlists.add(playlist); | |
debug('Created playlist:', uniqueName, 'with', prompts.length, 'prompts'); | |
return { ...playlist, id }; | |
} catch (error) { | |
debug('Error creating playlist:', error); | |
throw error; | |
} | |
} | |
async generateUniqueName(baseName) { | |
const existing = await db.playlists.where('name').startsWithIgnoreCase(baseName).toArray(); | |
if (!existing.some(p => p.name === baseName)) { | |
return baseName; | |
} | |
let counter = 2; | |
let uniqueName; | |
do { | |
uniqueName = `${baseName} (${counter})`; | |
counter++; | |
} while (existing.some(p => p.name === uniqueName)); | |
return uniqueName; | |
} | |
async getAllPlaylists() { | |
try { | |
return await db.playlists.orderBy('updated').reverse().toArray(); | |
} catch (error) { | |
debug('Error fetching playlists:', error); | |
return []; | |
} | |
} | |
async updatePlaylist(id, updates) { | |
try { | |
await db.playlists.update(id, { ...updates, updated: new Date() }); | |
debug('Updated playlist:', id); | |
// If this is an active playlist, refresh its data | |
if (this.activePlaylists.has(id)) { | |
const updatedPlaylist = await db.playlists.get(id); | |
if (updatedPlaylist) { | |
this.activePlaylists.set(id, updatedPlaylist); | |
debug('Refreshed active playlist data:', id); | |
} | |
} | |
} catch (error) { | |
debug('Error updating playlist:', error); | |
throw error; | |
} | |
} | |
async deletePlaylist(id) { | |
try { | |
await db.playlists.delete(id); | |
debug('Deleted playlist:', id); | |
} catch (error) { | |
debug('Error deleting playlist:', error); | |
throw error; | |
} | |
} | |
async startPlaylist(playlistId) { | |
// Check for other tabs running | |
if (localStorage.getItem(CONFIG.STORAGE_BROADCAST_KEY) && this.runningState === 'stopped') { | |
showToast('Playlist runner active in another tab', 'warning'); | |
return false; | |
} | |
try { | |
const playlist = await db.playlists.get(playlistId); | |
if (!playlist || playlist.prompts.length === 0) { | |
showToast('Playlist is empty or not found', 'error'); | |
return false; | |
} | |
// Check if already running | |
if (this.activePlaylists.has(playlistId)) { | |
showToast('Playlist is already running', 'warning'); | |
return false; | |
} | |
// Add to active playlists | |
this.activePlaylists.set(playlistId, playlist); | |
// Start the system if not already running | |
if (this.runningState === 'stopped') { | |
this.runningState = 'running'; | |
localStorage.setItem(CONFIG.STORAGE_BROADCAST_KEY, 'true'); | |
this.startDispatchLoop(); | |
} | |
showToast(`Started playlist: ${playlist.name}`, 'success'); | |
return true; | |
} catch (error) { | |
debug('Error starting playlist:', error); | |
showToast('Failed to start playlist', 'error'); | |
return false; | |
} | |
} | |
pausePlaylist() { | |
if (this.runningState === 'running') { | |
this.runningState = 'paused'; | |
this.stopDispatchLoop(); | |
showToast('All playlists paused', 'info'); | |
} | |
} | |
stopPlaylist(playlistId = null) { | |
if (playlistId && this.activePlaylists.has(playlistId)) { | |
// Stop specific playlist | |
const playlist = this.activePlaylists.get(playlistId); | |
this.activePlaylists.delete(playlistId); | |
// Clean up related in-flight and retry entries | |
for (const [key, data] of this.inFlightPrompts.entries()) { | |
if (key.toString().startsWith(`${playlistId}-`)) { | |
this.inFlightPrompts.delete(key); | |
} | |
} | |
for (const [key, data] of this.retryQueue.entries()) { | |
if (key.toString().startsWith(`${playlistId}-`)) { | |
this.retryQueue.delete(key); | |
} | |
} | |
showToast(`Stopped playlist: ${playlist.name}`, 'info'); | |
// If no more active playlists, stop the system | |
if (this.activePlaylists.size === 0) { | |
this.stopAllPlaylists(); | |
} | |
} else { | |
// Stop all playlists | |
this.stopAllPlaylists(); | |
} | |
} | |
stopAllPlaylists() { | |
this.runningState = 'stopped'; | |
this.activePlaylists.clear(); | |
this.inFlightPrompts.clear(); | |
this.retryQueue.clear(); | |
this.stopDispatchLoop(); | |
this.roundRobinIndex = 0; | |
localStorage.removeItem(CONFIG.STORAGE_BROADCAST_KEY); | |
showToast('All playlists stopped', 'info'); | |
} | |
resetProgress(playlistId = null) { | |
if (playlistId && this.activePlaylists.has(playlistId)) { | |
const playlist = this.activePlaylists.get(playlistId); | |
playlist.lastIndexSent = 0; | |
this.updatePlaylist(playlistId, { lastIndexSent: 0 }); | |
// Clean up related entries | |
for (const [key, data] of this.inFlightPrompts.entries()) { | |
if (key.toString().startsWith(`${playlistId}-`)) { | |
this.inFlightPrompts.delete(key); | |
} | |
} | |
for (const [key, data] of this.retryQueue.entries()) { | |
if (key.toString().startsWith(`${playlistId}-`)) { | |
this.retryQueue.delete(key); | |
} | |
} | |
showToast(`Progress reset for: ${playlist.name}`, 'info'); | |
} else { | |
// Reset all active playlists | |
for (const [id, playlist] of this.activePlaylists.entries()) { | |
playlist.lastIndexSent = 0; | |
this.updatePlaylist(id, { lastIndexSent: 0 }); | |
} | |
this.inFlightPrompts.clear(); | |
this.retryQueue.clear(); | |
showToast('Progress reset for all playlists', 'info'); | |
} | |
} | |
startDispatchLoop() { | |
if (this.dispatchTimer) return; | |
const loop = () => { | |
if (this.runningState === 'running') { | |
this.tryDispatchNext(); | |
this.dispatchTimer = setTimeout(loop, 100); | |
} | |
}; | |
this.dispatchTimer = setTimeout(loop, 100); | |
} | |
stopDispatchLoop() { | |
if (this.dispatchTimer) { | |
clearTimeout(this.dispatchTimer); | |
this.dispatchTimer = null; | |
} | |
} | |
async tryDispatchNext() { | |
if (this.activePlaylists.size === 0 || this.runningState !== 'running') return; | |
// Check if we can dispatch more | |
if (this.inFlightPrompts.size >= CONFIG.MAX_IN_FLIGHT) return; | |
// Throttle check | |
const now = Date.now(); | |
if (now - this.lastDispatchTime < CONFIG.THROTTLE_MS) return; | |
// Check Higgsfield UI state | |
if (!this.canSubmitPrompt()) return; | |
// Process retry queue first | |
for (const [promptKey, retryData] of this.retryQueue) { | |
if (retryData.nextRetry <= now && this.inFlightPrompts.size < CONFIG.MAX_IN_FLIGHT) { | |
await this.dispatchPrompt(promptKey, retryData.prompt, retryData.attempts + 1, retryData.playlistId); | |
this.retryQueue.delete(promptKey); | |
this.lastDispatchTime = now; | |
return; | |
} | |
} | |
// Round-robin dispatch from active playlists | |
const activePlaylistIds = Array.from(this.activePlaylists.keys()); | |
if (activePlaylistIds.length === 0) return; | |
let attempts = 0; | |
while (attempts < activePlaylistIds.length) { | |
// Ensure roundRobinIndex is within bounds | |
this.roundRobinIndex = this.roundRobinIndex % activePlaylistIds.length; | |
const currentId = activePlaylistIds[this.roundRobinIndex]; | |
const playlist = this.activePlaylists.get(currentId); | |
if (playlist && playlist.lastIndexSent < playlist.prompts.length) { | |
const promptIndex = playlist.lastIndexSent; | |
const prompt = playlist.prompts[promptIndex]; | |
const promptKey = `${currentId}-${promptIndex}`; | |
await this.dispatchPrompt(promptKey, prompt, 0, currentId); | |
playlist.lastIndexSent = promptIndex + 1; | |
await this.updatePlaylist(currentId, { lastIndexSent: promptIndex + 1 }); | |
this.lastDispatchTime = now; | |
// Move to next playlist for next dispatch | |
this.roundRobinIndex = (this.roundRobinIndex + 1) % activePlaylistIds.length; | |
return; | |
} | |
// This playlist is done, move to next | |
this.roundRobinIndex = (this.roundRobinIndex + 1) % activePlaylistIds.length; | |
attempts++; | |
} | |
// Check if all playlists are complete | |
if (this.inFlightPrompts.size === 0 && this.retryQueue.size === 0) { | |
const allComplete = Array.from(this.activePlaylists.values()).every(playlist => | |
playlist.lastIndexSent >= playlist.prompts.length | |
); | |
if (allComplete) { | |
const completedNames = Array.from(this.activePlaylists.values()).map(p => p.name).join(', '); | |
this.stopAllPlaylists(); | |
showToast(`All playlists completed: ${completedNames}`, 'success'); | |
} | |
} | |
} | |
canSubmitPrompt() { | |
try { | |
const preloadGradients = document.querySelectorAll('.video-media-preload-gradient'); | |
const submitButton = document.querySelector('button[type="submit"]'); | |
const gradientCheck = preloadGradients.length <= 3; | |
const buttonCheck = submitButton && | |
(submitButton.innerText.trim() === 'Generate\nUnlimited' || | |
/Generate\s+Unlimited/i.test(submitButton.innerText.trim())); | |
return gradientCheck && buttonCheck; | |
} catch (error) { | |
debug('Error checking UI state:', error); | |
showToast('Higgsfield layout changed', 'error'); | |
return false; | |
} | |
} | |
async dispatchPrompt(promptKey, prompt, attempts, playlistId) { | |
debug('Dispatching prompt', promptKey, 'attempt', attempts + 1); | |
// Check for duplicates | |
const isDuplicate = await isPromptDuplicate(prompt); | |
if (isDuplicate) { | |
debug('Skipping duplicate prompt:', prompt.substring(0, 50) + '...'); | |
showToast('Skipped duplicate prompt', 'info'); | |
// Mark as completed immediately to continue with next prompt | |
this.handlePromptCompletion(promptKey, true); | |
return; | |
} | |
this.inFlightPrompts.set(promptKey, { | |
prompt, | |
attempts, | |
startTime: Date.now(), | |
playlistId | |
}); | |
try { | |
await this.submitPromptWithEnhancement(prompt); | |
// Store hash after successful submission | |
await storePromptHash(prompt); | |
// Set timeout for completion detection | |
setTimeout(() => { | |
if (this.inFlightPrompts.has(promptKey)) { | |
this.handlePromptCompletion(promptKey, false); | |
} | |
}, 30000); // 30 second timeout | |
} catch (error) { | |
debug('Error dispatching prompt:', error); | |
this.handlePromptCompletion(promptKey, false); | |
} | |
} | |
async submitPromptWithEnhancement(promptText) { | |
// Get the UI manager instance to check enhancement setting | |
const uiManager = window.hfUIManager; | |
const shouldEnhance = uiManager && uiManager.enhancePrompts; | |
let finalPrompt = promptText; | |
if (shouldEnhance) { | |
finalPrompt = CONFIG.ENHANCEMENT_PREFIX + promptText; | |
debug('Enhanced prompt:', finalPrompt.substring(0, 80) + '...'); | |
} | |
// Call the actual submission logic directly | |
return new Promise((resolve, reject) => { | |
try { | |
// Try multiple selectors for the prompt textarea | |
const selectors = [ | |
'textarea[id*="prompt"]', | |
'textarea[placeholder*="prompt" i]', | |
'textarea[name*="prompt" i]', | |
'textarea[data-testid*="prompt"]', | |
'textarea[aria-label*="prompt" i]', | |
'textarea' | |
]; | |
let textarea = null; | |
for (const selector of selectors) { | |
const element = document.querySelector(selector); | |
if (element && element.offsetParent !== null) { // Check if visible | |
textarea = element; | |
break; | |
} | |
} | |
const submitButton = document.querySelector('button[type="submit"]'); | |
if (!textarea || !submitButton) { | |
reject(new Error('Could not find prompt input or submit button')); | |
return; | |
} | |
// Focus the textarea first | |
textarea.focus(); | |
// Set value using React/Vue compatible method | |
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; | |
nativeInputValueSetter.call(textarea, finalPrompt); | |
// Trigger events that React/Vue apps listen for | |
const inputEvent = new Event('input', { bubbles: true, cancelable: true }); | |
const changeEvent = new Event('change', { bubbles: true, cancelable: true }); | |
const keyupEvent = new KeyboardEvent('keyup', { bubbles: true, cancelable: true }); | |
textarea.dispatchEvent(inputEvent); | |
textarea.dispatchEvent(changeEvent); | |
textarea.dispatchEvent(keyupEvent); | |
// Verify the value was set | |
debug('Textarea value after setting:', textarea.value); | |
// Wait a bit for the app to process the input | |
setTimeout(() => { | |
submitButton.click(); | |
debug('Submitted prompt:', finalPrompt.substring(0, 50) + '...'); | |
resolve(); | |
}, 500); | |
} catch (error) { | |
debug('Error submitting prompt:', error); | |
reject(error); | |
} | |
}); | |
} | |
submitPrompt(promptText) { | |
try { | |
// Try multiple selectors for the prompt textarea | |
const selectors = [ | |
'textarea[id*="prompt"]', | |
'textarea[placeholder*="prompt" i]', | |
'textarea[name*="prompt" i]', | |
'textarea[data-testid*="prompt"]', | |
'textarea[aria-label*="prompt" i]', | |
'textarea' | |
]; | |
let textarea = null; | |
for (const selector of selectors) { | |
const element = document.querySelector(selector); | |
if (element && element.offsetParent !== null) { // Check if visible | |
textarea = element; | |
break; | |
} | |
} | |
const submitButton = document.querySelector('button[type="submit"]'); | |
if (!textarea || !submitButton) { | |
throw new Error('Could not find prompt input or submit button'); | |
} | |
// Focus the textarea first | |
textarea.focus(); | |
// Set value using React/Vue compatible method | |
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; | |
nativeInputValueSetter.call(textarea, promptText); | |
// Trigger events that React/Vue apps listen for | |
const inputEvent = new Event('input', { bubbles: true, cancelable: true }); | |
const changeEvent = new Event('change', { bubbles: true, cancelable: true }); | |
const keyupEvent = new KeyboardEvent('keyup', { bubbles: true, cancelable: true }); | |
textarea.dispatchEvent(inputEvent); | |
textarea.dispatchEvent(changeEvent); | |
textarea.dispatchEvent(keyupEvent); | |
// Verify the value was set | |
debug('Textarea value after setting:', textarea.value); | |
// Wait a bit for the app to process the input | |
setTimeout(() => { | |
submitButton.click(); | |
debug('Submitted prompt:', promptText.substring(0, 50) + '...'); | |
}, 500); | |
} catch (error) { | |
debug('Error submitting prompt:', error); | |
throw error; | |
} | |
} | |
handlePromptCompletion(promptKey, success) { | |
const promptData = this.inFlightPrompts.get(promptKey); | |
if (!promptData) return; | |
this.inFlightPrompts.delete(promptKey); | |
if (!success && promptData.attempts < CONFIG.MAX_RETRIES) { | |
// Add to retry queue with exponential backoff | |
const delay = Math.min(1000 * Math.pow(2, promptData.attempts), 60000); | |
this.retryQueue.set(promptKey, { | |
prompt: promptData.prompt, | |
attempts: promptData.attempts, | |
nextRetry: Date.now() + delay, | |
playlistId: promptData.playlistId | |
}); | |
debug('Queued prompt for retry:', promptKey, 'delay:', delay); | |
} else if (!success) { | |
debug('Prompt failed permanently:', promptKey); | |
const [playlistId, promptIndex] = promptKey.split('-'); | |
showToast(`Prompt ${parseInt(promptIndex) + 1} failed after ${CONFIG.MAX_RETRIES} attempts`, 'error'); | |
} else { | |
debug('Prompt completed successfully:', promptKey); | |
} | |
} | |
getRunningStatus() { | |
if (this.activePlaylists.size === 0) return null; | |
const playlists = []; | |
let totalPrompts = 0; | |
let totalCompleted = 0; | |
for (const [id, playlist] of this.activePlaylists.entries()) { | |
const completed = Math.max(0, playlist.lastIndexSent - this.getInFlightCountForPlaylist(id)); | |
const progress = playlist.prompts.length > 0 ? (completed / playlist.prompts.length) * 100 : 0; | |
const onDeck = []; | |
for (let i = playlist.lastIndexSent; i < Math.min(playlist.lastIndexSent + 4, playlist.prompts.length); i++) { | |
onDeck.push({ | |
index: i + 1, | |
prompt: playlist.prompts[i].substring(0, 30) + '...' | |
}); | |
} | |
playlists.push({ | |
id, | |
name: playlist.name, | |
progress: Math.round(progress), | |
completed, | |
total: playlist.prompts.length, | |
onDeck | |
}); | |
totalPrompts += playlist.prompts.length; | |
totalCompleted += completed; | |
} | |
const overallProgress = totalPrompts > 0 ? (totalCompleted / totalPrompts) * 100 : 0; | |
return { | |
state: this.runningState, | |
overallProgress: Math.round(overallProgress), | |
totalCompleted, | |
totalPrompts, | |
playlists | |
}; | |
} | |
getInFlightCountForPlaylist(playlistId) { | |
let count = 0; | |
for (const [key, data] of this.inFlightPrompts.entries()) { | |
if (key.toString().startsWith(`${playlistId}-`)) { | |
count++; | |
} | |
} | |
return count; | |
} | |
isPlaylistRunning(playlistId) { | |
return this.activePlaylists.has(playlistId); | |
} | |
} | |
// UI Management | |
class UIManager { | |
constructor(playlistManager) { | |
this.playlistManager = playlistManager; | |
this.shadowRoot = null; | |
this.isExpanded = false; | |
this.currentTab = 'playlists'; | |
this.isDragging = false; | |
this.dragOffset = { x: 0, y: 0 }; | |
this.position = { x: window.innerWidth - 50, y: 20 }; | |
this.enhancePrompts = false; | |
} | |
init() { | |
this.createShadowRoot(); | |
this.createFAB(); | |
this.setupEventListeners(); | |
this.setupDOMObserver(); | |
debug('UI initialized'); | |
} | |
createShadowRoot() { | |
const container = document.createElement('div'); | |
container.id = 'hf-playlist-container'; | |
container.style.cssText = ` | |
position: fixed !important; | |
top: 0 !important; | |
left: 0 !important; | |
width: 100% !important; | |
height: 100% !important; | |
pointer-events: none !important; | |
z-index: ${CONFIG.UI_Z_INDEX} !important; | |
`; | |
document.body.appendChild(container); | |
this.shadowRoot = container.attachShadow({ mode: 'closed' }); | |
this.shadowRoot.innerHTML = this.getStyles(); | |
this.container = container; | |
} | |
createFAB() { | |
const fab = document.createElement('div'); | |
fab.className = 'hf-fab'; | |
fab.innerHTML = '🚀'; | |
fab.style.left = this.position.x + 'px'; | |
fab.style.top = this.position.y + 'px'; | |
this.shadowRoot.appendChild(fab); | |
this.fab = fab; | |
} | |
setupEventListeners() { | |
// FAB events | |
this.fab.addEventListener('mousedown', this.handleMouseDown.bind(this)); | |
this.fab.addEventListener('click', this.handleFABClick.bind(this)); | |
this.fab.addEventListener('dblclick', this.toggleMinimize.bind(this)); | |
// Global events | |
document.addEventListener('mousemove', this.handleMouseMove.bind(this)); | |
document.addEventListener('mouseup', this.handleMouseUp.bind(this)); | |
window.addEventListener('resize', this.handleResize.bind(this)); | |
window.addEventListener('beforeunload', this.cleanup.bind(this)); | |
// Network events | |
window.addEventListener('online', () => { | |
if (this.playlistManager.runningState === 'paused') { | |
this.playlistManager.runningState = 'running'; | |
this.playlistManager.startDispatchLoop(); | |
showToast('Connection restored, resuming playlist', 'success'); | |
} | |
}); | |
window.addEventListener('offline', () => { | |
if (this.playlistManager.runningState === 'running') { | |
this.playlistManager.pausePlaylist(); | |
showToast('Connection lost, pausing playlist', 'warning'); | |
} | |
}); | |
} | |
setupDOMObserver() { | |
// Watch for DOM changes that might remove our container | |
const observer = new MutationObserver(() => { | |
if (!document.contains(this.container)) { | |
debug('Container removed, re-inserting...'); | |
document.body.appendChild(this.container); | |
} | |
}); | |
observer.observe(document.body, { | |
childList: true, | |
subtree: true | |
}); | |
this.domObserver = observer; | |
} | |
handleMouseDown(e) { | |
if (e.detail === 1) { // Single click | |
this.isDragging = true; | |
this.dragOffset.x = e.clientX - this.position.x; | |
this.dragOffset.y = e.clientY - this.position.y; | |
e.preventDefault(); | |
} | |
} | |
handleMouseMove(e) { | |
if (this.isDragging) { | |
this.position.x = e.clientX - this.dragOffset.x; | |
this.position.y = e.clientY - this.dragOffset.y; | |
// Keep within viewport | |
this.position.x = Math.max(0, Math.min(window.innerWidth - 40, this.position.x)); | |
this.position.y = Math.max(0, Math.min(window.innerHeight - 40, this.position.y)); | |
this.fab.style.left = this.position.x + 'px'; | |
this.fab.style.top = this.position.y + 'px'; | |
} | |
} | |
handleMouseUp() { | |
this.isDragging = false; | |
} | |
handleFABClick() { | |
if (!this.isDragging) { | |
this.togglePanel(); | |
} | |
} | |
toggleMinimize() { | |
// TODO: Implement minimize functionality | |
debug('Toggle minimize'); | |
} | |
handleResize() { | |
// Ensure FAB stays in viewport | |
this.position.x = Math.max(0, Math.min(window.innerWidth - 40, this.position.x)); | |
this.position.y = Math.max(0, Math.min(window.innerHeight - 40, this.position.y)); | |
this.fab.style.left = this.position.x + 'px'; | |
this.fab.style.top = this.position.y + 'px'; | |
} | |
togglePanel() { | |
this.isExpanded = !this.isExpanded; | |
if (this.isExpanded) { | |
this.createPanel(); | |
} else { | |
this.removePanel(); | |
} | |
} | |
async createPanel() { | |
if (this.shadowRoot.querySelector('.hf-panel')) return; | |
const panel = document.createElement('div'); | |
panel.className = 'hf-panel'; | |
panel.innerHTML = await this.getPanelHTML(); | |
this.shadowRoot.appendChild(panel); | |
this.setupPanelEvents(); | |
// Ensure the active tab is visible immediately | |
this.switchTabs(); | |
this.updateUI(); | |
} | |
removePanel() { | |
const panel = this.shadowRoot.querySelector('.hf-panel'); | |
if (panel) { | |
panel.remove(); | |
} | |
} | |
async getPanelHTML() { | |
const playlistsActive = this.currentTab === 'playlists' ? 'active' : ''; | |
const runningActive = this.currentTab === 'running' ? 'active' : ''; | |
const playlistsChecked = this.currentTab === 'playlists' ? 'checked' : ''; | |
const runningChecked = this.currentTab === 'running' ? 'checked' : ''; | |
return ` | |
<div class="hf-panel-header"> | |
<div class="hf-tabs"> | |
<input type="radio" id="tab-playlists" name="tabs" ${playlistsChecked}> | |
<label for="tab-playlists">Playlists</label> | |
<input type="radio" id="tab-running" name="tabs" ${runningChecked}> | |
<label for="tab-running">Running</label> | |
</div> | |
<button class="hf-close-btn">×</button> | |
</div> | |
<div class="hf-panel-content"> | |
<div class="hf-tab-content ${playlistsActive}" id="playlists-tab"> | |
<div class="hf-search"> | |
<input type="text" placeholder="Search playlists..." id="playlist-search"> | |
</div> | |
<div class="hf-enhancement-toggle"> | |
<label class="hf-checkbox-label"> | |
<input type="checkbox" id="enhance-prompts" ${this.enhancePrompts ? 'checked' : ''}> | |
<span class="hf-checkbox-text">✨ Enhance prompts</span> | |
</label> | |
</div> | |
<div class="hf-playlist-list" id="playlist-list"></div> | |
<button class="hf-btn hf-btn-primary" id="new-playlist-btn">+ New Playlist</button> | |
</div> | |
<div class="hf-tab-content ${runningActive}" id="running-tab"> | |
<div class="hf-running-status" id="running-status"></div> | |
</div> | |
</div> | |
`; | |
} | |
setupPanelEvents() { | |
// Tab switching | |
this.shadowRoot.querySelectorAll('input[name="tabs"]').forEach(tab => { | |
tab.addEventListener('change', (e) => { | |
this.currentTab = e.target.id.replace('tab-', ''); | |
this.switchTabs(); | |
this.updateUI(); | |
}); | |
}); | |
// Close button | |
this.shadowRoot.querySelector('.hf-close-btn').addEventListener('click', () => { | |
this.togglePanel(); | |
}); | |
// New playlist button | |
const newPlaylistBtn = this.shadowRoot.querySelector('#new-playlist-btn'); | |
debug('New playlist button found:', !!newPlaylistBtn); | |
if (newPlaylistBtn) { | |
newPlaylistBtn.addEventListener('click', () => { | |
debug('New playlist button clicked'); | |
this.showNewPlaylistModal(); | |
}); | |
} | |
// Search | |
this.shadowRoot.querySelector('#playlist-search').addEventListener('input', (e) => { | |
this.filterPlaylists(e.target.value); | |
}); | |
// Enhancement toggle | |
const enhanceToggle = this.shadowRoot.querySelector('#enhance-prompts'); | |
if (enhanceToggle) { | |
enhanceToggle.addEventListener('change', (e) => { | |
this.enhancePrompts = e.target.checked; | |
debug('Prompt enhancement:', this.enhancePrompts ? 'enabled' : 'disabled'); | |
showToast( | |
this.enhancePrompts ? 'Prompt enhancement enabled (affects pending prompts only)' : 'Prompt enhancement disabled', | |
'info' | |
); | |
// Note: This only affects future prompts, doesn't restart current queue | |
}); | |
} | |
} | |
switchTabs() { | |
debug('Switching to tab:', this.currentTab); | |
const allTabs = this.shadowRoot.querySelectorAll('.hf-tab-content'); | |
debug('Found tabs:', allTabs.length); | |
allTabs.forEach(tab => { | |
tab.classList.remove('active'); | |
debug('Removed active from:', tab.id); | |
}); | |
const activeTab = this.shadowRoot.querySelector(`#${this.currentTab}-tab`); | |
debug('Active tab element:', activeTab?.id); | |
if (activeTab) { | |
activeTab.classList.add('active'); | |
debug('Added active to:', activeTab.id); | |
} | |
} | |
async updateUI() { | |
if (!this.isExpanded) return; | |
if (this.currentTab === 'playlists') { | |
await this.updatePlaylistsTab(); | |
} else if (this.currentTab === 'running') { | |
this.updateRunningTab(); | |
} | |
} | |
async updatePlaylistsTab() { | |
const listContainer = this.shadowRoot.querySelector('#playlist-list'); | |
if (!listContainer) return; | |
try { | |
const playlists = await this.playlistManager.getAllPlaylists(); | |
listContainer.innerHTML = playlists.map(playlist => { | |
const isRunning = this.playlistManager.isPlaylistRunning(playlist.id); | |
const runningClass = isRunning ? 'hf-playlist-running' : ''; | |
const statusIcon = isRunning ? '🟢' : ''; | |
const buttonIcon = isRunning ? '⏹' : '▶️'; | |
const buttonClass = isRunning ? 'hf-stop-btn' : 'hf-start-btn'; | |
return ` | |
<div class="hf-playlist-item ${runningClass}" data-id="${playlist.id}"> | |
<div class="hf-playlist-info"> | |
<span class="hf-playlist-name">${statusIcon} ${playlist.name}</span> | |
<span class="hf-playlist-meta">(${playlist.prompts.length}) • ${playlist.lastIndexSent}/${playlist.prompts.length}</span> | |
</div> | |
<div class="hf-playlist-actions"> | |
<button class="hf-btn hf-btn-small ${buttonClass}" data-id="${playlist.id}">${buttonIcon}</button> | |
<button class="hf-btn hf-btn-small hf-menu-btn" data-id="${playlist.id}">⋯</button> | |
</div> | |
</div> | |
`; | |
}).join(''); | |
// Add event listeners | |
listContainer.querySelectorAll('.hf-start-btn').forEach(btn => { | |
btn.addEventListener('click', (e) => { | |
const id = parseInt(e.target.dataset.id); | |
this.playlistManager.startPlaylist(id); | |
setTimeout(() => this.updatePlaylistsTab(), 100); // Refresh to show new state | |
}); | |
}); | |
listContainer.querySelectorAll('.hf-stop-btn').forEach(btn => { | |
btn.addEventListener('click', (e) => { | |
const id = parseInt(e.target.dataset.id); | |
this.playlistManager.stopPlaylist(id); | |
setTimeout(() => this.updatePlaylistsTab(), 100); // Refresh to show new state | |
}); | |
}); | |
listContainer.querySelectorAll('.hf-menu-btn').forEach(btn => { | |
btn.addEventListener('click', (e) => { | |
const id = parseInt(e.target.dataset.id); | |
this.showPlaylistMenu(id); | |
}); | |
}); | |
} catch (error) { | |
debug('Error updating playlists tab:', error); | |
listContainer.innerHTML = '<div class="hf-error">Error loading playlists</div>'; | |
} | |
} | |
updateRunningTab() { | |
const statusContainer = this.shadowRoot.querySelector('#running-status'); | |
if (!statusContainer) return; | |
const status = this.playlistManager.getRunningStatus(); | |
if (!status) { | |
statusContainer.innerHTML = '<div class="hf-no-running">No playlists running</div>'; | |
return; | |
} | |
const playlistsHtml = status.playlists.map(playlist => ` | |
<div class="hf-running-playlist"> | |
<div class="hf-running-header"> | |
<h3>▸ ${playlist.name}</h3> | |
<div class="hf-progress"> | |
<div class="hf-progress-bar" style="width: ${playlist.progress}%"></div> | |
<span>${playlist.completed} / ${playlist.total}</span> | |
</div> | |
</div> | |
<div class="hf-running-queues"> | |
<div class="hf-queue"> | |
<h4>⏳ On-Deck</h4> | |
${playlist.onDeck.length > 0 ? | |
playlist.onDeck.map(item => `<div>#${item.index} "${item.prompt}"</div>`).join('') : | |
'<div class="hf-empty-queue">All prompts dispatched</div>' | |
} | |
</div> | |
</div> | |
<div class="hf-playlist-controls"> | |
<button class="hf-btn hf-btn-small hf-stop-playlist-btn" data-id="${playlist.id}">⏹</button> | |
<button class="hf-btn hf-btn-small hf-reset-playlist-btn" data-id="${playlist.id}">🔄</button> | |
</div> | |
</div> | |
`).join(''); | |
statusContainer.innerHTML = ` | |
<div class="hf-overall-status"> | |
<h3>Overall Progress</h3> | |
<div class="hf-progress"> | |
<div class="hf-progress-bar" style="width: ${status.overallProgress}%"></div> | |
<span>${status.totalCompleted} / ${status.totalPrompts}</span> | |
</div> | |
</div> | |
<div class="hf-playlists-container"> | |
${playlistsHtml} | |
</div> | |
<div class="hf-global-controls"> | |
<button class="hf-btn ${status.state === 'running' ? 'hf-pause-btn' : 'hf-play-btn'}" id="play-pause-btn"> | |
${status.state === 'running' ? '⏸' : '▶️'} | |
</button> | |
<button class="hf-btn hf-stop-btn" id="stop-all-btn">⏹ All</button> | |
<button class="hf-btn hf-reset-btn" id="reset-all-btn">🔄 All</button> | |
</div> | |
`; | |
// Add global control event listeners | |
const playPauseBtn = statusContainer.querySelector('#play-pause-btn'); | |
const stopAllBtn = statusContainer.querySelector('#stop-all-btn'); | |
const resetAllBtn = statusContainer.querySelector('#reset-all-btn'); | |
playPauseBtn?.addEventListener('click', () => { | |
if (status.state === 'running') { | |
this.playlistManager.pausePlaylist(); | |
} else { | |
this.playlistManager.runningState = 'running'; | |
this.playlistManager.startDispatchLoop(); | |
} | |
setTimeout(() => this.updateRunningTab(), 100); | |
}); | |
stopAllBtn?.addEventListener('click', () => { | |
this.playlistManager.stopAllPlaylists(); | |
setTimeout(() => this.updateRunningTab(), 100); | |
}); | |
resetAllBtn?.addEventListener('click', () => { | |
this.playlistManager.resetProgress(); | |
setTimeout(() => this.updateRunningTab(), 100); | |
}); | |
// Add individual playlist control event listeners | |
statusContainer.querySelectorAll('.hf-stop-playlist-btn').forEach(btn => { | |
btn.addEventListener('click', (e) => { | |
const id = parseInt(e.target.dataset.id); | |
this.playlistManager.stopPlaylist(id); | |
setTimeout(() => this.updateRunningTab(), 100); | |
}); | |
}); | |
statusContainer.querySelectorAll('.hf-reset-playlist-btn').forEach(btn => { | |
btn.addEventListener('click', (e) => { | |
const id = parseInt(e.target.dataset.id); | |
this.playlistManager.resetProgress(id); | |
setTimeout(() => this.updateRunningTab(), 100); | |
}); | |
}); | |
} | |
filterPlaylists(searchTerm) { | |
const items = this.shadowRoot.querySelectorAll('.hf-playlist-item'); | |
items.forEach(item => { | |
const name = item.querySelector('.hf-playlist-name').textContent.toLowerCase(); | |
const matches = name.includes(searchTerm.toLowerCase()); | |
item.style.display = matches ? 'flex' : 'none'; | |
}); | |
} | |
showNewPlaylistModal() { | |
debug('Opening new playlist modal'); | |
this.showModal('New Playlist', ` | |
<div class="hf-form-group"> | |
<label>Name:</label> | |
<input type="text" id="playlist-name" placeholder="Enter playlist name"> | |
</div> | |
<div class="hf-form-group"> | |
<label>Prompts (separated by ~):</label> | |
<textarea id="playlist-prompts" rows="10" placeholder="Prompt 1 ~ Prompt 2 ~ Prompt 3"></textarea> | |
</div> | |
`, async (modal) => { | |
const name = modal.querySelector('#playlist-name').value.trim(); | |
const prompts = modal.querySelector('#playlist-prompts').value.trim(); | |
if (!name || !prompts) { | |
showToast('Please fill in all fields', 'error'); | |
return false; | |
} | |
try { | |
await this.playlistManager.createPlaylist(name, prompts); | |
showToast('Playlist created successfully', 'success'); | |
this.updateUI(); | |
return true; | |
} catch (error) { | |
showToast('Failed to create playlist', 'error'); | |
return false; | |
} | |
}); | |
} | |
async showPlaylistMenu(playlistId) { | |
debug('Show playlist menu for:', playlistId); | |
try { | |
const playlist = await db.playlists.get(playlistId); | |
if (!playlist) return; | |
const menu = document.createElement('div'); | |
menu.className = 'hf-context-menu'; | |
menu.innerHTML = ` | |
<div class="hf-context-menu-item" data-action="rename">✏️ Rename</div> | |
<div class="hf-context-menu-item" data-action="replace">🔄 Replace</div> | |
<div class="hf-context-menu-item" data-action="append">➕ Append</div> | |
<div class="hf-context-menu-item" data-action="export">📤 Export</div> | |
<div class="hf-context-menu-item" data-action="delete">🗑️ Delete</div> | |
`; | |
// Position menu near the button | |
const button = this.shadowRoot.querySelector(`[data-id="${playlistId}"].hf-menu-btn`); | |
const rect = button.getBoundingClientRect(); | |
menu.style.cssText = ` | |
position: absolute; | |
top: ${rect.bottom + 5}px; | |
left: ${rect.left - 100}px; | |
z-index: ${CONFIG.UI_Z_INDEX + 1}; | |
`; | |
this.shadowRoot.appendChild(menu); | |
// Add event listeners | |
menu.addEventListener('click', async (e) => { | |
const action = e.target.dataset.action; | |
if (action) { | |
await this.handlePlaylistAction(playlistId, action, playlist); | |
menu.remove(); | |
} | |
}); | |
// Close menu when clicking outside | |
const closeMenu = (e) => { | |
if (!menu.contains(e.target)) { | |
menu.remove(); | |
document.removeEventListener('click', closeMenu); | |
} | |
}; | |
setTimeout(() => { | |
document.addEventListener('click', closeMenu); | |
}, 100); | |
} catch (error) { | |
debug('Error showing playlist menu:', error); | |
} | |
} | |
async handlePlaylistAction(playlistId, action, playlist) { | |
debug('Playlist action:', action, 'for playlist:', playlistId); | |
switch (action) { | |
case 'rename': | |
this.showRenameModal(playlistId, playlist.name); | |
break; | |
case 'replace': | |
this.showReplaceModal(playlistId, playlist); | |
break; | |
case 'append': | |
this.showAppendModal(playlistId, playlist); | |
break; | |
case 'export': | |
this.exportPlaylist(playlist); | |
break; | |
case 'delete': | |
if (confirm(`Delete playlist "${playlist.name}"?`)) { | |
await this.playlistManager.deletePlaylist(playlistId); | |
showToast('Playlist deleted', 'success'); | |
this.updateUI(); | |
} | |
break; | |
} | |
} | |
showRenameModal(playlistId, currentName) { | |
this.showModal('Rename Playlist', ` | |
<div class="hf-form-group"> | |
<label>Name:</label> | |
<input type="text" id="playlist-name" value="${currentName}" placeholder="Enter playlist name"> | |
</div> | |
`, async (modal) => { | |
const name = modal.querySelector('#playlist-name').value.trim(); | |
if (!name) { | |
showToast('Please enter a name', 'error'); | |
return false; | |
} | |
try { | |
const uniqueName = await this.playlistManager.generateUniqueName(name); | |
await this.playlistManager.updatePlaylist(playlistId, { name: uniqueName }); | |
showToast('Playlist renamed', 'success'); | |
this.updateUI(); | |
return true; | |
} catch (error) { | |
showToast('Failed to rename playlist', 'error'); | |
return false; | |
} | |
}); | |
} | |
showReplaceModal(playlistId, playlist) { | |
this.showModal('Replace Playlist', ` | |
<div class="hf-form-group"> | |
<label>Prompts (separated by ~):</label> | |
<textarea id="playlist-prompts" rows="10" placeholder="Prompt 1 ~ Prompt 2 ~ Prompt 3">${playlist.prompts.join(' ~ ')}</textarea> | |
</div> | |
`, async (modal) => { | |
const prompts = modal.querySelector('#playlist-prompts').value.trim(); | |
if (!prompts) { | |
showToast('Please enter prompts', 'error'); | |
return false; | |
} | |
try { | |
const promptsArray = prompts.split('~').map(p => p.trim()).filter(p => p); | |
await this.playlistManager.updatePlaylist(playlistId, { | |
prompts: promptsArray, | |
lastIndexSent: 0 | |
}); | |
showToast('Playlist replaced', 'success'); | |
this.updateUI(); | |
return true; | |
} catch (error) { | |
showToast('Failed to replace playlist', 'error'); | |
return false; | |
} | |
}); | |
} | |
showAppendModal(playlistId, playlist) { | |
this.showModal('Append to Playlist', ` | |
<div class="hf-form-group"> | |
<label>Additional Prompts (separated by ~):</label> | |
<textarea id="playlist-prompts" rows="6" placeholder="New prompt 1 ~ New prompt 2"></textarea> | |
</div> | |
`, async (modal) => { | |
const prompts = modal.querySelector('#playlist-prompts').value.trim(); | |
if (!prompts) { | |
showToast('Please enter prompts to append', 'error'); | |
return false; | |
} | |
try { | |
const newPrompts = prompts.split('~').map(p => p.trim()).filter(p => p); | |
const updatedPrompts = [...playlist.prompts, ...newPrompts]; | |
await this.playlistManager.updatePlaylist(playlistId, { prompts: updatedPrompts }); | |
showToast(`Added ${newPrompts.length} prompts`, 'success'); | |
// Update UI for both tabs | |
await this.updateUI(); | |
// If this playlist is currently running, refresh the active playlist data | |
if (this.playlistManager.activePlaylists.has(playlistId)) { | |
const updatedPlaylist = await db.playlists.get(playlistId); | |
if (updatedPlaylist) { | |
this.playlistManager.activePlaylists.set(playlistId, updatedPlaylist); | |
} | |
if (this.currentTab === 'running') { | |
this.updateRunningTab(); | |
} | |
} | |
return true; | |
} catch (error) { | |
showToast('Failed to append prompts', 'error'); | |
return false; | |
} | |
}); | |
} | |
exportPlaylist(playlist) { | |
const exportData = { | |
name: playlist.name, | |
prompts: playlist.prompts, | |
created: playlist.created, | |
updated: playlist.updated | |
}; | |
const jsonString = JSON.stringify(exportData, null, 2); | |
if (navigator.clipboard) { | |
navigator.clipboard.writeText(jsonString).then(() => { | |
showToast('Playlist copied to clipboard', 'success'); | |
}).catch(() => { | |
this.fallbackCopyToClipboard(jsonString); | |
}); | |
} else { | |
this.fallbackCopyToClipboard(jsonString); | |
} | |
} | |
fallbackCopyToClipboard(text) { | |
const textArea = document.createElement('textarea'); | |
textArea.value = text; | |
document.body.appendChild(textArea); | |
textArea.select(); | |
try { | |
document.execCommand('copy'); | |
showToast('Playlist copied to clipboard', 'success'); | |
} catch (err) { | |
showToast('Failed to copy to clipboard', 'error'); | |
} | |
document.body.removeChild(textArea); | |
} | |
showModal(title, content, onSave) { | |
debug('Creating modal:', title); | |
const modal = document.createElement('div'); | |
modal.className = 'hf-modal-overlay'; | |
modal.innerHTML = ` | |
<div class="hf-modal"> | |
<div class="hf-modal-header"> | |
<h3>${title}</h3> | |
<button class="hf-modal-close">×</button> | |
</div> | |
<div class="hf-modal-content">${content}</div> | |
<div class="hf-modal-footer"> | |
<button class="hf-btn hf-btn-secondary hf-modal-cancel">Cancel</button> | |
<button class="hf-btn hf-btn-primary hf-modal-save">Save</button> | |
</div> | |
</div> | |
`; | |
this.shadowRoot.appendChild(modal); | |
// Event listeners | |
const closeModal = () => modal.remove(); | |
modal.querySelector('.hf-modal-close').addEventListener('click', closeModal); | |
modal.querySelector('.hf-modal-cancel').addEventListener('click', closeModal); | |
modal.addEventListener('click', (e) => { | |
if (e.target === modal) closeModal(); | |
}); | |
modal.querySelector('.hf-modal-save').addEventListener('click', async () => { | |
if (onSave) { | |
const shouldClose = await onSave(modal); | |
if (shouldClose) closeModal(); | |
} else { | |
closeModal(); | |
} | |
}); | |
// Focus first input | |
const firstInput = modal.querySelector('input, textarea'); | |
if (firstInput) firstInput.focus(); | |
} | |
cleanup() { | |
this.playlistManager.stopPlaylist(); | |
if (this.domObserver) { | |
this.domObserver.disconnect(); | |
} | |
debug('UI cleanup completed'); | |
} | |
getStyles() { | |
return ` | |
<style> | |
* { | |
box-sizing: border-box; | |
margin: 0; | |
padding: 0; | |
} | |
.hf-fab { | |
position: fixed !important; | |
width: 28px; | |
height: 28px; | |
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important; | |
border-radius: 50%; | |
display: flex !important; | |
align-items: center; | |
justify-content: center; | |
cursor: pointer !important; | |
user-select: none; | |
z-index: ${CONFIG.UI_Z_INDEX} !important; | |
font-size: 12px; | |
box-shadow: 0 4px 20px rgba(0,0,0,0.4) !important; | |
transition: transform 0.2s; | |
pointer-events: auto !important; | |
opacity: 1 !important; | |
visibility: visible !important; | |
border: 2px solid rgba(255,255,255,0.3) !important; | |
} | |
.hf-fab:hover { | |
transform: scale(1.1); | |
} | |
.hf-panel { | |
position: fixed; | |
top: 60px; | |
right: 12px; | |
width: 320px; | |
max-height: 70vh; | |
background: rgba(255, 255, 255, 0.95); | |
backdrop-filter: blur(10px); | |
border-radius: 12px; | |
box-shadow: 0 8px 32px rgba(0,0,0,0.2); | |
z-index: ${CONFIG.UI_Z_INDEX - 1} !important; | |
overflow: hidden; | |
border: 1px solid rgba(255,255,255,0.2); | |
pointer-events: auto !important; | |
color: #333 !important; | |
} | |
.hf-panel-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
padding: 12px; | |
border-bottom: 1px solid rgba(0,0,0,0.1); | |
background: rgba(255,255,255,0.5); | |
} | |
.hf-tabs { | |
display: flex; | |
gap: 8px; | |
} | |
.hf-tabs input[type="radio"] { | |
display: none; | |
} | |
.hf-tabs label { | |
padding: 4px 12px; | |
border-radius: 16px; | |
cursor: pointer; | |
font-size: 12px; | |
background: rgba(0,0,0,0.1); | |
transition: all 0.2s; | |
color: #333 !important; | |
} | |
.hf-tabs input[type="radio"]:checked + label { | |
background: #667eea; | |
color: white !important; | |
} | |
.hf-close-btn { | |
background: none; | |
border: none; | |
font-size: 16px; | |
cursor: pointer; | |
padding: 4px; | |
border-radius: 4px; | |
color: #333 !important; | |
} | |
.hf-close-btn:hover { | |
background: rgba(0,0,0,0.1); | |
} | |
.hf-panel-content { | |
padding: 12px; | |
max-height: calc(70vh - 60px); | |
overflow-y: auto; | |
} | |
.hf-tab-content { | |
display: none; | |
} | |
.hf-tab-content.active { | |
display: block; | |
} | |
.hf-search input { | |
width: 100%; | |
padding: 8px; | |
border: 1px solid rgba(0,0,0,0.2); | |
border-radius: 6px; | |
margin-bottom: 8px; | |
font-size: 12px; | |
} | |
.hf-enhancement-toggle { | |
margin-bottom: 12px; | |
padding: 8px; | |
background: rgba(102, 126, 234, 0.1); | |
border-radius: 6px; | |
border: 1px solid rgba(102, 126, 234, 0.2); | |
} | |
.hf-checkbox-label { | |
display: flex !important; | |
align-items: center !important; | |
gap: 8px !important; | |
cursor: pointer !important; | |
font-size: 12px !important; | |
color: #333 !important; | |
} | |
.hf-checkbox-label input[type="checkbox"] { | |
width: auto !important; | |
margin: 0 !important; | |
padding: 0 !important; | |
} | |
.hf-checkbox-text { | |
font-weight: 500 !important; | |
color: #667eea !important; | |
} | |
.hf-playlist-item { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
padding: 8px; | |
margin-bottom: 4px; | |
border-radius: 6px; | |
background: rgba(0,0,0,0.02); | |
transition: all 0.2s; | |
} | |
.hf-playlist-item.hf-playlist-running { | |
background: rgba(102, 126, 234, 0.1); | |
border: 1px solid rgba(102, 126, 234, 0.3); | |
} | |
.hf-playlist-info { | |
flex: 1; | |
min-width: 0; | |
} | |
.hf-playlist-name { | |
display: block; | |
font-size: 12px; | |
font-weight: 500; | |
white-space: nowrap; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
} | |
.hf-playlist-meta { | |
font-size: 10px; | |
color: #666; | |
} | |
.hf-playlist-actions { | |
display: flex; | |
gap: 4px; | |
} | |
.hf-btn { | |
border: none; | |
border-radius: 4px; | |
cursor: pointer; | |
font-size: 12px; | |
transition: all 0.2s; | |
} | |
.hf-btn-small { | |
padding: 4px 8px; | |
font-size: 10px; | |
} | |
.hf-btn-primary { | |
background: #667eea !important; | |
color: white !important; | |
padding: 8px 16px; | |
width: 100%; | |
margin-top: 12px; | |
} | |
.hf-btn-secondary { | |
background: rgba(0,0,0,0.1); | |
color: #333 !important; | |
padding: 8px 16px; | |
} | |
.hf-btn:hover { | |
opacity: 0.8; | |
transform: translateY(-1px); | |
} | |
.hf-running-header h3 { | |
font-size: 14px; | |
margin-bottom: 8px; | |
} | |
.hf-progress { | |
position: relative; | |
height: 20px; | |
background: rgba(0,0,0,0.1); | |
border-radius: 10px; | |
overflow: hidden; | |
margin-bottom: 12px; | |
} | |
.hf-progress-bar { | |
height: 100%; | |
background: linear-gradient(90deg, #667eea, #764ba2); | |
transition: width 0.3s; | |
} | |
.hf-progress span { | |
position: absolute; | |
top: 50%; | |
left: 50%; | |
transform: translate(-50%, -50%); | |
font-size: 10px; | |
font-weight: bold; | |
color: #333; | |
} | |
.hf-running-queues { | |
display: grid; | |
grid-template-columns: 1fr 1fr; | |
gap: 12px; | |
margin-bottom: 12px; | |
} | |
.hf-queue h4 { | |
font-size: 10px; | |
margin-bottom: 4px; | |
color: #666; | |
} | |
.hf-queue > div { | |
font-size: 9px; | |
padding: 2px 4px; | |
background: rgba(0,0,0,0.05); | |
border-radius: 3px; | |
margin-bottom: 2px; | |
white-space: nowrap; | |
overflow: hidden; | |
text-overflow: ellipsis; | |
} | |
.hf-overall-status { | |
margin-bottom: 16px; | |
padding-bottom: 12px; | |
border-bottom: 1px solid rgba(0,0,0,0.1); | |
} | |
.hf-overall-status h3 { | |
font-size: 14px; | |
margin-bottom: 8px; | |
color: #333; | |
} | |
.hf-playlists-container { | |
max-height: 300px; | |
overflow-y: auto; | |
margin-bottom: 16px; | |
} | |
.hf-running-playlist { | |
margin-bottom: 16px; | |
padding: 12px; | |
background: rgba(0,0,0,0.02); | |
border-radius: 8px; | |
border: 1px solid rgba(0,0,0,0.1); | |
} | |
.hf-playlist-controls { | |
display: flex; | |
gap: 4px; | |
justify-content: flex-end; | |
margin-top: 8px; | |
} | |
.hf-global-controls { | |
display: flex; | |
gap: 8px; | |
justify-content: center; | |
padding-top: 12px; | |
border-top: 1px solid rgba(0,0,0,0.1); | |
} | |
.hf-global-controls .hf-btn { | |
padding: 8px 12px; | |
background: rgba(0,0,0,0.1); | |
} | |
.hf-empty-queue { | |
font-size: 9px; | |
color: #999; | |
font-style: italic; | |
padding: 2px 4px; | |
} | |
.hf-no-running { | |
text-align: center; | |
color: #666; | |
font-size: 12px; | |
padding: 20px; | |
} | |
.hf-error { | |
color: #e74c3c; | |
font-size: 12px; | |
text-align: center; | |
padding: 20px; | |
} | |
.hf-modal-overlay { | |
position: fixed !important; | |
top: 0 !important; | |
left: 0 !important; | |
right: 0 !important; | |
bottom: 0 !important; | |
width: 100vw !important; | |
height: 100vh !important; | |
background: rgba(0,0,0,0.5) !important; | |
display: flex !important; | |
align-items: center !important; | |
justify-content: center !important; | |
z-index: ${CONFIG.UI_Z_INDEX + 10} !important; | |
pointer-events: auto !important; | |
} | |
.hf-modal { | |
background: white !important; | |
border-radius: 12px !important; | |
width: 90% !important; | |
max-width: 400px !important; | |
max-height: 80vh !important; | |
overflow: hidden !important; | |
box-shadow: 0 10px 40px rgba(0,0,0,0.3) !important; | |
pointer-events: auto !important; | |
position: relative !important; | |
color: #333 !important; | |
} | |
.hf-modal-header { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
padding: 16px; | |
border-bottom: 1px solid rgba(0,0,0,0.1); | |
background: #f8f9fa; | |
} | |
.hf-modal-header h3 { | |
font-size: 16px; | |
margin: 0; | |
color: #333 !important; | |
} | |
.hf-modal-close { | |
background: none; | |
border: none; | |
font-size: 20px; | |
cursor: pointer; | |
padding: 4px; | |
border-radius: 4px; | |
color: #333 !important; | |
} | |
.hf-modal-content { | |
padding: 16px; | |
max-height: 50vh; | |
overflow-y: auto; | |
} | |
.hf-modal-footer { | |
display: flex; | |
gap: 8px; | |
padding: 16px; | |
border-top: 1px solid rgba(0,0,0,0.1); | |
background: #f8f9fa; | |
justify-content: flex-end; | |
} | |
.hf-form-group { | |
margin-bottom: 16px; | |
} | |
.hf-form-group label { | |
display: block; | |
margin-bottom: 4px; | |
font-size: 12px; | |
font-weight: 500; | |
color: #333 !important; | |
} | |
.hf-form-group input, | |
.hf-form-group textarea { | |
width: 100%; | |
padding: 8px; | |
border: 1px solid rgba(0,0,0,0.2); | |
border-radius: 6px; | |
font-size: 12px; | |
font-family: inherit; | |
resize: vertical; | |
color: #333 !important; | |
background: white !important; | |
} | |
.hf-toast { | |
position: fixed; | |
top: 80px; | |
right: 12px; | |
padding: 12px 16px; | |
border-radius: 8px; | |
color: white; | |
font-size: 12px; | |
z-index: ${CONFIG.UI_Z_INDEX + 5}; | |
animation: hf-toast-in 0.3s ease-out; | |
max-width: 300px; | |
word-wrap: break-word; | |
pointer-events: auto !important; | |
} | |
.hf-toast.success { background: #27ae60; } | |
.hf-toast.error { background: #e74c3c; } | |
.hf-toast.warning { background: #f39c12; } | |
.hf-toast.info { background: #3498db; } | |
.hf-context-menu { | |
background: white !important; | |
border-radius: 8px !important; | |
box-shadow: 0 4px 20px rgba(0,0,0,0.2) !important; | |
border: 1px solid rgba(0,0,0,0.1) !important; | |
overflow: hidden !important; | |
min-width: 140px !important; | |
pointer-events: auto !important; | |
} | |
.hf-context-menu-item { | |
padding: 8px 12px !important; | |
cursor: pointer !important; | |
font-size: 12px !important; | |
color: #333 !important; | |
display: flex !important; | |
align-items: center !important; | |
gap: 8px !important; | |
transition: background-color 0.2s !important; | |
} | |
.hf-context-menu-item:hover { | |
background-color: rgba(102, 126, 234, 0.1) !important; | |
} | |
.hf-context-menu-item:last-child { | |
color: #e74c3c !important; | |
} | |
@keyframes hf-toast-in { | |
from { | |
opacity: 0; | |
transform: translateY(20px); | |
} | |
to { | |
opacity: 1; | |
transform: translateY(0); | |
} | |
} | |
</style> | |
`; | |
} | |
} | |
// Toast notifications | |
function showToast(message, type = 'info') { | |
const container = document.querySelector('#hf-playlist-container'); | |
if (!container?.shadowRoot) return; | |
const toast = document.createElement('div'); | |
toast.className = `hf-toast ${type}`; | |
toast.textContent = message; | |
container.shadowRoot.appendChild(toast); | |
setTimeout(() => { | |
toast.remove(); | |
}, 3000); | |
debug('Toast:', type, message); | |
} | |
// Initialize the application | |
async function init() { | |
try { | |
debug('Initializing Higgsfield Playlist Automation...'); | |
await initDatabase(); | |
const playlistManager = new PlaylistManager(); | |
const uiManager = new UIManager(playlistManager); | |
// Set global reference for enhancement checking | |
window.hfUIManager = uiManager; | |
uiManager.init(); | |
// Update UI periodically when running | |
setInterval(() => { | |
if (uiManager.isExpanded) { | |
if (uiManager.currentTab === 'running') { | |
uiManager.updateRunningTab(); | |
} else if (uiManager.currentTab === 'playlists') { | |
// Refresh playlist list to show running indicators | |
uiManager.updatePlaylistsTab(); | |
} | |
} | |
}, 1000); | |
debug('Initialization complete'); | |
} catch (error) { | |
console.error('Failed to initialize Higgsfield Playlist Automation:', error); | |
} | |
} | |
// Start the application when DOM is ready | |
function delayedInit() { | |
// Wait a bit for the SPA to finish rendering | |
setTimeout(init, 2000); | |
} | |
if (document.readyState === 'loading') { | |
document.addEventListener('DOMContentLoaded', delayedInit); | |
} else { | |
delayedInit(); | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment