From 2a98654e540179acd6f9db10756dafa3b3824528 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Sun, 28 Dec 2025 14:27:34 +0100 Subject: [PATCH 1/2] feat: implement firebase synchronization for library and history - Added Firebase authentication (Google) and Realtime Database sync - Implemented 'Magic Link' configuration sharing - Increased local and cloud history limit to 1000 tracks - Refactored settings to support dynamic Firebase configuration - Added firebase-setup.md documentation --- firebase-setup.md | 74 ++++++++++++++ index.html | 30 ++++++ js/app.js | 16 +++ js/db.js | 49 ++++----- js/events.js | 20 ++-- js/firebase/auth.js | 92 +++++++++++++++++ js/firebase/config.js | 203 +++++++++++++++++++++++++++++++++++++ js/firebase/sync.js | 227 ++++++++++++++++++++++++++++++++++++++++++ js/settings.js | 7 ++ 9 files changed, 678 insertions(+), 40 deletions(-) create mode 100644 firebase-setup.md create mode 100644 js/firebase/auth.js create mode 100644 js/firebase/config.js create mode 100644 js/firebase/sync.js diff --git a/firebase-setup.md b/firebase-setup.md new file mode 100644 index 0000000..ee3bb35 --- /dev/null +++ b/firebase-setup.md @@ -0,0 +1,74 @@ +# Setting Up Firebase Sync for Monochrome + +Follow these steps to enable cross-device synchronization for your library, history, and settings using your own Firebase project. + +## 1. Create a Firebase Project +1. Go to the [Firebase Console](https://console.firebase.google.com/). +2. Click **Add project** and give it a name (e.g., "Monochrome Sync"). +3. (Optional) Disable Gemini and Google Analytics if you want to keep it simple. +4. Click **Create project**. + +## 2. Enable Authentication +1. In the left sidebar, click **Build** > **Authentication**. +2. Click **Get Started**. +3. Go to the **Sign-in method** tab. +4. Select **Google** and enable it. +5. Set your project support email and click **Save**. + +### 2.1 Authorized Domains (CRITICAL) +Firebase will block login attempts from unknown domains. +1. In the **Authentication** section, go to the **Settings** tab. +2. Click **Authorized domains** in the left sub-menu. +3. Click **Add domain**. +4. Add your hosting domain (e.g., `julienmaille.github.io`). + * *Note: `localhost` and `127.0.0.1` are usually added by default for local testing.* + +## 3. Enable Realtime Database +1. In the left sidebar, click **Build** > **Realtime Database**. +2. Click **Create Database**. +3. Choose a location near you and click **Next**. +4. Select **Start in test mode** (we will change the rules in the next step) and click **Enable**. + +## 4. Set Security Rules +1. In the Realtime Database section, go to the **Rules** tab. +2. Replace the existing rules with the following to ensure users can only see their own data: + ```json + { + "rules": { + "users": { + "$uid": { + ".read": "$uid === auth.uid", + ".write": "$uid === auth.uid" + } + } + } + } + ``` +3. Click **Publish**. + +## 5. Get Your Configuration +1. Click the gear icon (⚙️) next to "Project Overview" and select **Project settings**. +2. In the **General** tab, scroll down to "Your apps" and click the **Web icon (``)**. +3. Register the app (e.g., "Monochrome App"). +4. You will see a `firebaseConfig` object. It looks like this: + ```javascript + const firebaseConfig = { + apiKey: "AIzaSy...", + authDomain: "your-project.firebaseapp.com", + databaseURL: "https://your-project.firebaseio.com", + projectId: "your-project", + storageBucket: "your-project.appspot.com", + messagingSenderId: "...", + appId: "..." + }; + ``` +5. **Copy only the part with the curly braces `{ ... }`**. + +## 6. Configure Monochrome +1. Open the Monochrome app and go to **Settings**. +2. Find the **Firebase Configuration** section. +3. Paste the JSON object you copied into the textarea. +4. Click **Save & Reload**. +5. Under **Sync & Backup**, click **Connect with Google** and sign in. + +**Your library is now synced to the cloud!** Log in on any other device with the same configuration to see your music everywhere. diff --git a/index.html b/index.html index 7c07de8..7e53e8f 100644 --- a/index.html +++ b/index.html @@ -336,6 +336,36 @@ + +
+
+ Firebase Configuration + Paste your Firebase Config JSON here to enable sync. +
+
+ +
+ + + +
+
+
+ +
+
+ Sync & Backup (Beta) + Sync your library across devices +
+ + +
+
+
+ +
+
+
Audio Quality diff --git a/js/app.js b/js/app.js index f9b175f..d1b8f59 100644 --- a/js/app.js +++ b/js/app.js @@ -590,6 +590,22 @@ document.addEventListener('DOMContentLoaded', async () => { localStorage.setItem('shortcuts-shown', 'true'); }, 3000); } + + // Listener for Firebase Sync updates + window.addEventListener('library-changed', () => { + const hash = window.location.hash; + if (hash === '#library') { + ui.renderLibraryPage(); + } else if (hash === '#home' || hash === '') { + ui.renderHomePage(); + } + }); + window.addEventListener('history-changed', () => { + const hash = window.location.hash; + if (hash === '#recent') { + ui.renderRecentPage(); + } + }); }); function showUpdateNotification() { diff --git a/js/db.js b/js/db.js index 51421cf..eeeab90 100644 --- a/js/db.js +++ b/js/db.js @@ -41,8 +41,6 @@ export class MusicDatabase { const store = db.createObjectStore('favorites_playlists', { keyPath: 'uuid' }); store.createIndex('addedAt', 'addedAt', { unique: false }); } - - // History store if (!db.objectStoreNames.contains('history_tracks')) { const store = db.createObjectStore('history_tracks', { keyPath: 'timestamp' }); store.createIndex('timestamp', 'timestamp', { unique: true }); @@ -83,27 +81,7 @@ export class MusicDatabase { // Add new entry store.put(entry); - // Trim to 1000 - const index = store.index('timestamp'); - const countRequest = index.count(); - - countRequest.onsuccess = () => { - if (countRequest.result > 1000) { - // Get oldest keys - const cursorRequest = index.openCursor(); - let deleted = 0; - const toDelete = countRequest.result - 1000; - - cursorRequest.onsuccess = (e) => { - const cursor = e.target.result; - if (cursor && deleted < toDelete) { - cursor.delete(); - deleted++; - cursor.continue(); - } - }; - } - }; + return entry; } async getHistory() { @@ -133,7 +111,8 @@ export class MusicDatabase { await this.performTransaction(storeName, 'readwrite', (store) => store.delete(key)); return false; // Removed } else { - const entry = { ...item, addedAt: Date.now() }; + const minified = this._minifyItem(type, item); + const entry = { ...minified, addedAt: Date.now() }; await this.performTransaction(storeName, 'readwrite', (store) => store.put(entry)); return true; // Added } @@ -168,11 +147,11 @@ export class MusicDatabase { _minifyItem(type, item) { if (!item) return item; - + // Base properties to keep const base = { id: item.id, - addedAt: item.addedAt + addedAt: item.addedAt || null }; if (type === 'track') { @@ -187,12 +166,12 @@ export class MusicDatabase { album: item.album ? { id: item.album.id, cover: item.album.cover, - releaseDate: item.album.releaseDate + releaseDate: item.album.releaseDate || null } : null, // Fallback date streamStartDate: item.streamStartDate, // Keep version if exists - version: item.version + version: item.version || null }; } @@ -251,15 +230,21 @@ export class MusicDatabase { return data; } - async importData(data) { - // Clear existing? Or merge? Prompt says "Sync" or "Export/Import". + async importData(data, clear = false) { // Let's merge by put (replaces if ID exists). const db = await this.open(); - + const importStore = async (storeName, items) => { - if (!items || !Array.isArray(items)) return; + // If items is undefined, we skip this store (don't clear, don't update) + // This allows partial updates (e.g. library only) + if (items === undefined) return; + const transaction = db.transaction(storeName, 'readwrite'); const store = transaction.objectStore(storeName); + if (clear) { + store.clear(); + } + for (const item of items) { store.put(item); } diff --git a/js/events.js b/js/events.js index 4831b8c..afe457e 100644 --- a/js/events.js +++ b/js/events.js @@ -5,6 +5,7 @@ import { showNotification, downloadTrackWithMetadata } from './downloads.js'; import { lyricsSettings } from './storage.js'; import { updateTabTitle } from './router.js'; import { db } from './db.js'; +import { syncManager } from './firebase/sync.js'; export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { const playPauseBtn = document.querySelector('.play-pause-btn'); @@ -71,8 +72,9 @@ export function initializePlayerEvents(player, audioPlayer, scrobbler, ui) { // Log to history after 10 seconds of playback if (currentTime >= 10 && player.currentTrack && player.currentTrack.id !== historyLoggedTrackId) { historyLoggedTrackId = player.currentTrack.id; - await db.addToHistory(player.currentTrack); - + const historyEntry = await db.addToHistory(player.currentTrack); + syncManager.syncHistoryItem(historyEntry); + if (window.location.hash === '#recent') { ui.renderRecentPage(); } @@ -301,6 +303,7 @@ function initializeSmoothSliders(audioPlayer, player) { volumeBar.style.setProperty('--volume-level', `${position * 100}%`); localStorage.setItem('volume', position); }); + volumeBar.addEventListener('click', e => { if (!isAdjustingVolume) { seek(volumeBar, e, position => { @@ -352,19 +355,20 @@ export async function handleTrackAction(action, item, player, api, lyricsManager await downloadTrackWithMetadata(item, player.quality, api, lyricsManager); } else if (action === 'toggle-like') { const added = await db.toggleFavorite(type, item); - + syncManager.syncLibraryItem(type, item, added); + // Update all instances of this item's like button on the page const id = type === 'playlist' ? item.uuid : item.id; const selector = type === 'track' ? `[data-track-id="${id}"] .like-btn` : `.card[data-${type}-id="${id}"] .like-btn, .card[data-playlist-id="${id}"] .like-btn`; - + // Also check header buttons const headerBtn = document.getElementById(`like-${type}-btn`); - + const elementsToUpdate = [...document.querySelectorAll(selector)]; if (headerBtn) elementsToUpdate.push(headerBtn); - + const nowPlayingLikeBtn = document.getElementById('now-playing-like-btn'); if (nowPlayingLikeBtn && type === 'track' && player?.currentTrack?.id === item.id) { elementsToUpdate.push(nowPlayingLikeBtn); @@ -409,11 +413,11 @@ export async function handleTrackAction(action, item, player, api, lyricsManager // Create track element const index = tracksContainer.children.length; const trackHTML = ui.createTrackItemHTML(item, index, true, false); - + const tempDiv = document.createElement('div'); tempDiv.innerHTML = trackHTML; const newEl = tempDiv.firstElementChild; - + if (newEl) { tracksContainer.appendChild(newEl); trackDataStore.set(newEl, item); diff --git a/js/firebase/auth.js b/js/firebase/auth.js new file mode 100644 index 0000000..a6c7ed5 --- /dev/null +++ b/js/firebase/auth.js @@ -0,0 +1,92 @@ +// js/firebase/auth.js +import { auth, provider } from './config.js'; +import { signInWithPopup, signOut as firebaseSignOut, onAuthStateChanged } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js"; +import { syncManager } from './sync.js'; + +export class AuthManager { + constructor() { + this.user = null; + this.unsubscribe = null; + this.init(); + } + + init() { + if (!auth) return; + + this.unsubscribe = onAuthStateChanged(auth, (user) => { + this.user = user; + this.updateUI(user); + + if (user) { + console.log("User logged in:", user.uid); + syncManager.initialize(user); + } else { + console.log("User logged out"); + syncManager.disconnect(); + } + }); + } + + async signInWithGoogle() { + if (!auth) { + alert("Firebase is not configured. Please check console."); + return; + } + + try { + const result = await signInWithPopup(auth, provider); + // The onAuthStateChanged listener will handle the rest + return result.user; + } catch (error) { + console.error("Login failed:", error); + alert(`Login failed: ${error.message}`); + throw error; + } + } + + async signOut() { + if (!auth) return; + + try { + await firebaseSignOut(auth); + // The onAuthStateChanged listener will handle the rest + } catch (error) { + console.error("Logout failed:", error); + throw error; + } + } + + updateUI(user) { + const connectBtn = document.getElementById('firebase-connect-btn'); + const statusText = document.getElementById('firebase-status'); + const userAvatar = document.getElementById('firebase-user-avatar'); + const userName = document.getElementById('firebase-user-name'); + const container = document.getElementById('firebase-controls'); + + if (!connectBtn) return; // UI might not be rendered yet + + if (user) { + connectBtn.textContent = 'Sign Out'; + connectBtn.classList.add('danger'); + connectBtn.onclick = () => this.signOut(); + + if (statusText) statusText.textContent = `Signed in as ${user.email}`; + + // Optional: Show user info if elements exist + if (userAvatar && user.photoURL) userAvatar.src = user.photoURL; + if (userName) userName.textContent = user.displayName; + + } else { + connectBtn.textContent = 'Connect with Google'; + connectBtn.classList.remove('danger'); + connectBtn.onclick = () => this.signInWithGoogle(); + + if (statusText) statusText.textContent = 'Sync your library across devices'; + + if (userAvatar) userAvatar.src = ''; // Placeholder or clear + if (userName) userName.textContent = ''; + } + } +} + +export const authManager = new AuthManager(); diff --git a/js/firebase/config.js b/js/firebase/config.js new file mode 100644 index 0000000..ca9c945 --- /dev/null +++ b/js/firebase/config.js @@ -0,0 +1,203 @@ +// js/firebase/config.js +import { initializeApp } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js"; +import { getAuth, GoogleAuthProvider } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js"; +import { getDatabase } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js"; + +let app = null; +let auth = null; +let database = null; +let provider = null; + +const STORAGE_KEY = 'monochrome-firebase-config'; + +function getStoredConfig() { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return stored ? JSON.parse(stored) : null; + } catch (e) { + console.warn('Failed to parse Firebase config from storage', e); + return null; + } +} + +// Attempt to initialize on load +const config = getStoredConfig(); +if (config) { + try { + app = initializeApp(config); + auth = getAuth(app); + database = getDatabase(app); + provider = new GoogleAuthProvider(); + console.log("Firebase initialized from saved config"); + } catch (error) { + console.error("Error initializing Firebase from saved config:", error); + } +} else { + console.log("No Firebase config found in local storage."); +} + +export function saveFirebaseConfig(configObj) { + if (!configObj) return; + localStorage.setItem(STORAGE_KEY, JSON.stringify(configObj)); +} + +export function clearFirebaseConfig() { + localStorage.removeItem(STORAGE_KEY); +} + +/** + * Generates a shareable URL containing the encoded configuration. + * @param {Object} config - The Firebase configuration object. + * @returns {string} The full URL with the config hash. + */ +export function generateShareLink(config) { + if (!config) return null; + try { + const json = JSON.stringify(config); + // Base64 encode (safe for URL hash) + const encoded = btoa(json); + const url = new URL(window.location.href); + url.hash = `#setup_firebase=${encoded}`; + return url.toString(); + } catch (e) { + console.error('Failed to generate share link:', e); + return null; + } +} + +/** + * Checks the current URL for a shared configuration. + * If found, prompts the user to import it. + * @returns {boolean} True if a config was handled/processed. + */ +export function checkAndImportConfig() { + const hash = window.location.hash; + if (!hash.startsWith('#setup_firebase=')) return false; + + const encoded = hash.split('#setup_firebase=')[1]; + if (!encoded) return false; + + try { + const json = atob(encoded); + const config = JSON.parse(json); + + // Validate basic structure + if (!config.apiKey || !config.authDomain) { + alert('The shared configuration link appears to be invalid.'); + return false; + } + + if (confirm('A Firebase configuration was detected in the link. Do you want to import it and enable Sync?')) { + saveFirebaseConfig(config); + // Clean URL + window.history.replaceState(null, null, window.location.pathname); + alert('Configuration imported successfully! The app will now reload.'); + window.location.reload(); + return true; + } else { + // User rejected, clean URL anyway to avoid re-prompting + window.history.replaceState(null, null, window.location.pathname + '#settings'); + } + } catch (e) { + console.error('Failed to parse shared config:', e); + alert('Failed to read configuration from link. The link might be corrupted.'); + } + return false; +} + +export function initializeFirebaseSettingsUI() { + // Check for shared config in URL first + checkAndImportConfig(); + + const firebaseConfigInput = document.getElementById('firebase-config-input'); + const saveFirebaseConfigBtn = document.getElementById('save-firebase-config-btn'); + const clearFirebaseConfigBtn = document.getElementById('clear-firebase-config-btn'); + const shareFirebaseConfigBtn = document.getElementById('share-firebase-config-btn'); + + // Populate current config + if (firebaseConfigInput) { + const currentConfig = localStorage.getItem(STORAGE_KEY); + if (currentConfig) { + try { + firebaseConfigInput.value = JSON.stringify(JSON.parse(currentConfig), null, 2); + } catch (e) { + firebaseConfigInput.value = currentConfig; + } + } + } + + // Share Button + if (shareFirebaseConfigBtn) { + shareFirebaseConfigBtn.addEventListener('click', () => { + const currentConfigStr = localStorage.getItem(STORAGE_KEY); + if (!currentConfigStr) { + alert('No configuration saved to share.'); + return; + } + try { + const config = JSON.parse(currentConfigStr); + const link = generateShareLink(config); + if (link) { + navigator.clipboard.writeText(link).then(() => { + alert('Magic Link copied to clipboard! Send it to your other device.'); + }).catch(err => { + console.error('Clipboard error:', err); + prompt('Copy this link:', link); + }); + } + } catch (e) { + alert('Invalid configuration found.'); + } + }); + } + + // Save Button + if (saveFirebaseConfigBtn) { + saveFirebaseConfigBtn.addEventListener('click', () => { + const inputVal = firebaseConfigInput.value.trim(); + if (!inputVal) { + alert('Please enter a valid configuration.'); + return; + } + + try { + let cleaned = inputVal; + // Remove variable declaration if present (e.g., "const firebaseConfig = ") + if (cleaned.includes('=')) { + cleaned = cleaned.substring(cleaned.indexOf('=') + 1); + } + // Remove trailing semicolon + cleaned = cleaned.trim(); + if (cleaned.endsWith(';')) { + cleaned = cleaned.slice(0, -1); + } + + // Convert JS Object format to JSON format + const jsonReady = cleaned + .replace(/([{,]\s*)([a-zA-Z0-9_]+)\s*:/g, '$1"$2":') // Wrap keys in double quotes + .replace(/:\s*'([^']*)'/g, ': "$1"') // Replace single-quoted values with double quotes + .replace(/,\s*([}\]])/g, '$1'); // Remove trailing commas + + const config = JSON.parse(jsonReady); + saveFirebaseConfig(config); + alert('Configuration saved. Reloading...'); + window.location.reload(); + } catch (error) { + console.error('Invalid Config:', error); + alert('Could not parse configuration. Please ensure it looks like a valid JSON or JS object.'); + } + }); + } + + // Clear Button + if (clearFirebaseConfigBtn) { + clearFirebaseConfigBtn.addEventListener('click', () => { + if (confirm('Are you sure you want to clear the Firebase configuration? Sync will stop.')) { + clearFirebaseConfig(); + window.location.reload(); + } + }); + } +} + +export { app, auth, database, provider }; diff --git a/js/firebase/sync.js b/js/firebase/sync.js new file mode 100644 index 0000000..4c68676 --- /dev/null +++ b/js/firebase/sync.js @@ -0,0 +1,227 @@ +// js/firebase/sync.js +import { database } from './config.js'; +import { ref, get, set, update, onValue, off, child, remove, runTransaction } from "https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js"; +import { db } from '../db.js'; + +export class SyncManager { + constructor() { + this.user = null; + this.userRef = null; + this.unsubscribeFunctions = []; + this.isSyncing = false; + } + + initialize(user) { + if (!database || !user) return; + this.user = user; + this.userRef = ref(database, `users/${user.uid}`); + + console.log("Initializing SyncManager for user:", user.uid); + this.performInitialSync(); + } + + disconnect() { + if (this.userRef) { + // Remove listeners + this.unsubscribeFunctions.forEach(unsub => unsub()); + this.unsubscribeFunctions = []; + } + this.user = null; + this.userRef = null; + console.log("SyncManager disconnected"); + } + + async performInitialSync() { + if (this.isSyncing) return; + this.isSyncing = true; + + try { + console.log("Starting initial sync..."); + + // 1. Fetch Cloud Data + const snapshot = await get(this.userRef); + const cloudData = snapshot.val() || {}; + + // 2. Fetch Local Data + const localData = await db.exportData(); + + // 3. Merge Data (Union Strategy) + const mergedData = this.mergeData(localData, cloudData); + + // 4. Update Cloud (if different) + // We optimize by just rewriting the whole node for simplicity in Phase 1, + // or we could diff. Rewriting is safer for "Initial Merge". + await update(this.userRef, mergedData); + + // 5. Update Local (Import merged data) + // Convert Cloud Schema back to Local Schema for IndexedDB + const importData = { + favorites_tracks: mergedData.library?.tracks ? Object.values(mergedData.library.tracks) : [], + favorites_albums: mergedData.library?.albums ? Object.values(mergedData.library.albums) : [], + favorites_artists: mergedData.library?.artists ? Object.values(mergedData.library.artists) : [], + favorites_playlists: mergedData.library?.playlists ? Object.values(mergedData.library.playlists) : [], + history_tracks: mergedData.history?.recentTracks ? Object.values(mergedData.history.recentTracks) : [] + }; + + await db.importData(importData, true); + + console.log("Initial sync complete."); + + // 6. Setup Listeners for future changes + this.setupListeners(); + + } catch (error) { + console.error("Initial sync failed:", error); + } finally { + this.isSyncing = false; + } + } + + mergeData(local, cloud) { + // Helper to merge lists of objects based on ID/UUID + // We assume 'favorites_*' structure from db.exportData() + + const mergeStores = (localItems, cloudItems, idKey = 'id') => { + const map = new Map(); + + // Add all local items + if (Array.isArray(localItems)) { + localItems.forEach(item => map.set(item[idKey], item)); + } else if (localItems && typeof localItems === 'object') { + // Handle case where cloud stores as object keys + Object.values(localItems).forEach(item => map.set(item[idKey], item)); + } + + // Add/Overwrite with cloud items (Union Strategy) + if (cloudItems) { + if (Array.isArray(cloudItems)) { + cloudItems.forEach(item => map.set(item[idKey], item)); + } else { + Object.keys(cloudItems).forEach(key => { + const val = cloudItems[key]; + if (typeof val === 'object') { + map.set(val[idKey] || key, val); + } + }); + } + } + + return Array.from(map.values()); + }; + + const merged = { + library: { + tracks: this.arrayToObject(mergeStores(local.favorites_tracks, cloud.library?.tracks), 'id'), + albums: this.arrayToObject(mergeStores(local.favorites_albums, cloud.library?.albums), 'id'), + artists: this.arrayToObject(mergeStores(local.favorites_artists, cloud.library?.artists), 'id'), + playlists: this.arrayToObject(mergeStores(local.favorites_playlists, cloud.library?.playlists, 'uuid'), 'uuid') + }, + history: { + recentTracks: this.arrayToObject(mergeStores(local.history_tracks, cloud.history?.recentTracks, 'timestamp'), 'timestamp') + }, + // Settings are NOT synced (device specific) + lastUpdated: Date.now() + }; + + // Transform back to local structure for db.importData + return merged; + } + + // Helper to convert array to object with keys + arrayToObject(arr, keyField) { + const obj = {}; + arr.forEach(item => { + if (item && item[keyField]) { + obj[item[keyField]] = item; + } + }); + return obj; + } + + setupListeners() { + // Listen for changes in library + const libraryRef = child(this.userRef, 'library'); + + const unsubLibrary = onValue(libraryRef, (snapshot) => { + if (this.isSyncing) return; + + const val = snapshot.val(); + if (val) { + const importData = { + favorites_tracks: val.tracks ? Object.values(val.tracks) : [], + favorites_albums: val.albums ? Object.values(val.albums) : [], + favorites_artists: val.artists ? Object.values(val.artists) : [], + favorites_playlists: val.playlists ? Object.values(val.playlists) : [] + }; + db.importData(importData, true).then(() => { + // Notify UI to refresh + window.dispatchEvent(new Event('library-changed')); + }); + } + }); + + this.unsubscribeFunctions.push(() => off(libraryRef, 'value', unsubLibrary)); + + // Listen for changes in history + const historyRef = child(this.userRef, 'history/recentTracks'); + + const unsubHistory = onValue(historyRef, (snapshot) => { + if (this.isSyncing) return; + + const val = snapshot.val(); + if (val) { + const importData = { + history_tracks: Object.values(val) + }; + db.importData(importData, true).then(() => { + // Notify UI to refresh + window.dispatchEvent(new Event('history-changed')); + }); + } + }); + + this.unsubscribeFunctions.push(() => off(historyRef, 'value', unsubHistory)); + } + + // --- Public API for Broadcasters --- + + async syncLibraryItem(type, item, isAdded) { + if (!this.user || !this.userRef) return; + + // type: 'track', 'album', 'artist', 'playlist' + // item: the object (minified preferably) + // isAdded: boolean + + const categoryMap = { + 'track': 'tracks', + 'album': 'albums', + 'artist': 'artists', + 'playlist': 'playlists' + }; + const category = categoryMap[type]; + if (!category) return; + + const id = type === 'playlist' ? item.uuid : item.id; + const path = `library/${category}/${id}`; + const itemRef = child(this.userRef, path); + + if (isAdded) { + await set(itemRef, item); + } else { + await remove(itemRef); + } + } + + async syncHistoryItem(track) { + if (!this.user || !this.userRef || !track.timestamp) return; + + const itemRef = child(this.userRef, `history/recentTracks/${track.timestamp}`); + try { + await set(itemRef, track); + } catch (error) { + console.error("Failed to sync history item:", error); + } + } +} + +export const syncManager = new SyncManager(); diff --git a/js/settings.js b/js/settings.js index 8f82efd..3f2e296 100644 --- a/js/settings.js +++ b/js/settings.js @@ -1,8 +1,15 @@ //js/settings import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings, backgroundSettings, trackListSettings } from './storage.js'; import { db } from './db.js'; +import { authManager } from './firebase/auth.js'; +import { syncManager } from './firebase/sync.js'; +import { initializeFirebaseSettingsUI } from './firebase/config.js'; export function initializeSettings(scrobbler, player, api, ui) { + // Initialize Firebase UI & Settings + authManager.updateUI(authManager.user); + initializeFirebaseSettingsUI(); + const lastfmConnectBtn = document.getElementById('lastfm-connect-btn'); const lastfmStatus = document.getElementById('lastfm-status'); const lastfmToggle = document.getElementById('lastfm-toggle'); From 060d4762ccbb47c190329d669b0c64c8d54a21f8 Mon Sep 17 00:00:00 2001 From: Julien Maille Date: Mon, 29 Dec 2025 23:02:59 +0100 Subject: [PATCH 2/2] new: added default firebase instance, reworked settings layout --- index.html | 384 ++++++++++++++++++++++-------------------- js/firebase/auth.js | 5 + js/firebase/config.js | 42 ++++- js/firebase/sync.js | 18 +- js/settings.js | 13 ++ styles.css | 64 ++++++- 6 files changed, 338 insertions(+), 188 deletions(-) diff --git a/index.html b/index.html index 7e53e8f..0d50570 100644 --- a/index.html +++ b/index.html @@ -282,190 +282,212 @@

Settings

-
-
- Theme - Choose your preferred color scheme -
-
-
-
System
-
Light
-
Dark
-
Black
-
Ocean
-
Purple
-
Forest
-
Custom
-
-
-

Custom Theme

-
-
- - -
-
-
-
- Album Cover Background - Use the album cover as a blurred background on album pages and as primary color -
- -
-
-
- Last.fm Scrobbling - Connect your Last.fm account to scrobble tracks -
-
- -
-
- - - -
-
- Firebase Configuration - Paste your Firebase Config JSON here to enable sync. -
-
- -
- - - -
-
-
- -
-
- Sync & Backup (Beta) - Sync your library across devices -
- - -
-
-
- -
-
- -
-
- Audio Quality - Quality for streaming and downloads -
- -
-
-
- Now Playing View Mode - Choose what shows when you click the album art -
- -
-
-
- Track List Actions - Choose between a dropdown menu or inline buttons for track actions -
- -
-
-
- Download Lyrics - Include .lrc files when downloading tracks/albums -
- -
-
-
- Gapless Playback - Play audio without interruption between tracks -
- -
-
-
- Filename Template - Customize download filenames. Available: {trackNumber}, {artist}, {title}, {album} -
- -
-
-
- ZIP Folder Template - Customize album folder names. Available: {albumTitle}, {albumArtist}, {year} -
- -
-
-
- Keyboard Shortcuts - View available keyboard shortcuts -
- -
-
-
- Cache - Stores API responses to reduce requests -
- -
-
-
- Backup & Restore - Export or import your library and history as JSON -
-
- - - -
-
-
-
+
+
- API Instances - Manage and prioritize API instances. Automatically sorted by speed. + Theme + Choose your preferred color scheme
-
-
    +
    +
    System
    +
    Light
    +
    Dark
    +
    Black
    +
    Ocean
    +
    Purple
    +
    Forest
    +
    Custom
    +
    +
    +

    Custom Theme

    +
    +
    + + +
    +
    +
    +
    + Album Cover Background + Use the album cover as a blurred background on album pages and as primary color +
    + +
    +
    +
    +
    +
    + Last.fm Scrobbling + Connect your Last.fm account to scrobble tracks +
    +
    + +
    +
    + + +
    + +
    +
    +
    + Firebase Configuration + Manage your database connection. +
    +
    + +
    +

    Default shared instance is active. Override below only if needed.

    + +
    + + + +
    +
    +
    +
    + +
    +
    + Sync & Backup (Beta) + Sync your library across devices +
    + + +
    +
    +
    + + +
    +
    +
    + +
    +
    +
    + Audio Quality + Quality for streaming and downloads +
    + +
    +
    +
    + Gapless Playback + Play audio without interruption between tracks +
    + +
    +
    + +
    +
    +
    + Now Playing View Mode + Choose what shows when you click the album art +
    + +
    +
    +
    + Track List Actions + Choose between a dropdown menu or inline buttons for track actions +
    + +
    +
    + +
    +
    +
    + Download Lyrics + Include .lrc files when downloading tracks/albums +
    + +
    +
    +
    + Filename Template + Customize download filenames. Available: {trackNumber}, {artist}, {title}, {album} +
    + +
    +
    +
    + ZIP Folder Template + Customize album folder names. Available: {albumTitle}, {albumArtist}, {year} +
    + +
    +
    + +
    +
    +
    + Keyboard Shortcuts + View available keyboard shortcuts +
    + +
    +
    +
    + Cache + Stores API responses to reduce requests +
    + +
    +
    +
    + Backup & Restore + Export or import your library and history as JSON +
    +
    + + + +
    +
    +
    +
    +
    + API Instances + Manage and prioritize API instances. Automatically sorted by speed. +
    + +
    +
      +
      diff --git a/js/firebase/auth.js b/js/firebase/auth.js index a6c7ed5..f7fca2c 100644 --- a/js/firebase/auth.js +++ b/js/firebase/auth.js @@ -58,6 +58,7 @@ export class AuthManager { updateUI(user) { const connectBtn = document.getElementById('firebase-connect-btn'); + const clearDataBtn = document.getElementById('firebase-clear-cloud-btn'); const statusText = document.getElementById('firebase-status'); const userAvatar = document.getElementById('firebase-user-avatar'); const userName = document.getElementById('firebase-user-name'); @@ -70,6 +71,8 @@ export class AuthManager { connectBtn.classList.add('danger'); connectBtn.onclick = () => this.signOut(); + if (clearDataBtn) clearDataBtn.style.display = 'block'; + if (statusText) statusText.textContent = `Signed in as ${user.email}`; // Optional: Show user info if elements exist @@ -81,6 +84,8 @@ export class AuthManager { connectBtn.classList.remove('danger'); connectBtn.onclick = () => this.signInWithGoogle(); + if (clearDataBtn) clearDataBtn.style.display = 'none'; + if (statusText) statusText.textContent = 'Sync your library across devices'; if (userAvatar) userAvatar.src = ''; // Placeholder or clear diff --git a/js/firebase/config.js b/js/firebase/config.js index ca9c945..9134572 100644 --- a/js/firebase/config.js +++ b/js/firebase/config.js @@ -10,6 +10,15 @@ let provider = null; const STORAGE_KEY = 'monochrome-firebase-config'; +const DEFAULT_CONFIG = { + apiKey: "AIzaSyDPU-unAjuLtQJt4IkGS5faG50UCF7lYyA", + authDomain: "monochrome-database.firebaseapp.com", + projectId: "monochrome-database", + storageBucket: "monochrome-database.firebasestorage.app", + messagingSenderId: "895657412760", + appId: "1:895657412760:web:e81c5044c7f4e9b799e8ed" +}; + function getStoredConfig() { try { const stored = localStorage.getItem(STORAGE_KEY); @@ -21,19 +30,21 @@ function getStoredConfig() { } // Attempt to initialize on load -const config = getStoredConfig(); +const storedConfig = getStoredConfig(); +const config = storedConfig || DEFAULT_CONFIG; + if (config) { try { app = initializeApp(config); auth = getAuth(app); database = getDatabase(app); provider = new GoogleAuthProvider(); - console.log("Firebase initialized from saved config"); + console.log("Firebase initialized from " + (storedConfig ? "saved" : "default") + " config"); } catch (error) { - console.error("Error initializing Firebase from saved config:", error); + console.error("Error initializing Firebase:", error); } } else { - console.log("No Firebase config found in local storage."); + console.log("No Firebase config found."); } export function saveFirebaseConfig(configObj) { @@ -113,6 +124,22 @@ export function initializeFirebaseSettingsUI() { const saveFirebaseConfigBtn = document.getElementById('save-firebase-config-btn'); const clearFirebaseConfigBtn = document.getElementById('clear-firebase-config-btn'); const shareFirebaseConfigBtn = document.getElementById('share-firebase-config-btn'); + const toggleFirebaseConfigBtn = document.getElementById('toggle-firebase-config-btn'); + const customFirebaseConfigContainer = document.getElementById('custom-firebase-config-container'); + + // Toggle Button Logic + if (toggleFirebaseConfigBtn && customFirebaseConfigContainer) { + toggleFirebaseConfigBtn.addEventListener('click', () => { + const isVisible = customFirebaseConfigContainer.classList.contains('visible'); + if (isVisible) { + customFirebaseConfigContainer.classList.remove('visible'); + toggleFirebaseConfigBtn.textContent = 'Advanced: Custom Configuration'; + } else { + customFirebaseConfigContainer.classList.add('visible'); + toggleFirebaseConfigBtn.textContent = 'Hide Custom Configuration'; + } + }); + } // Populate current config if (firebaseConfigInput) { @@ -120,6 +147,11 @@ export function initializeFirebaseSettingsUI() { if (currentConfig) { try { firebaseConfigInput.value = JSON.stringify(JSON.parse(currentConfig), null, 2); + // If custom config exists, show the container + if (customFirebaseConfigContainer && toggleFirebaseConfigBtn) { + customFirebaseConfigContainer.classList.add('visible'); + toggleFirebaseConfigBtn.textContent = 'Hide Custom Configuration'; + } } catch (e) { firebaseConfigInput.value = currentConfig; } @@ -192,7 +224,7 @@ export function initializeFirebaseSettingsUI() { // Clear Button if (clearFirebaseConfigBtn) { clearFirebaseConfigBtn.addEventListener('click', () => { - if (confirm('Are you sure you want to clear the Firebase configuration? Sync will stop.')) { + if (confirm('Are you sure you want to remove the custom configuration? The app will revert to the shared default database.')) { clearFirebaseConfig(); window.location.reload(); } diff --git a/js/firebase/sync.js b/js/firebase/sync.js index 4c68676..2ea1513 100644 --- a/js/firebase/sync.js +++ b/js/firebase/sync.js @@ -206,7 +206,16 @@ export class SyncManager { const itemRef = child(this.userRef, path); if (isAdded) { - await set(itemRef, item); + // Minify to ensure consistency and reduce bandwidth + // We use the db helper to ensure consistent structure + const minified = db._minifyItem(type, item); + // Ensure addedAt is present. If the passed item didn't have it (e.g. from player), + // we add it now. Ideally this matches local DB time, but a small diff is negligible. + const entry = { + ...minified, + addedAt: item.addedAt || minified.addedAt || Date.now() + }; + await set(itemRef, entry); } else { await remove(itemRef); } @@ -222,6 +231,13 @@ export class SyncManager { console.error("Failed to sync history item:", error); } } + + async clearCloudData() { + if (!this.user || !this.userRef) { + throw new Error("Not authenticated"); + } + await remove(this.userRef); + } } export const syncManager = new SyncManager(); diff --git a/js/settings.js b/js/settings.js index 3f2e296..9074a97 100644 --- a/js/settings.js +++ b/js/settings.js @@ -298,6 +298,19 @@ export function initializeSettings(scrobbler, player, api, ui) { } }); + document.getElementById('firebase-clear-cloud-btn')?.addEventListener('click', async () => { + if (confirm('Are you sure you want to delete ALL your data from the cloud? This cannot be undone.')) { + try { + await syncManager.clearCloudData(); + alert('Cloud data cleared successfully.'); + authManager.signOut(); + } catch (error) { + console.error('Failed to clear cloud data:', error); + alert('Failed to clear cloud data: ' + error.message); + } + } + }); + // Backup & Restore document.getElementById('export-library-btn')?.addEventListener('click', async () => { const data = await db.exportData(); diff --git a/styles.css b/styles.css index d1e76fb..d065865 100644 --- a/styles.css +++ b/styles.css @@ -174,6 +174,16 @@ a { text-decoration: none; } +.text-link { + color: var(--primary); + text-decoration: underline; + cursor: pointer; +} + +.text-link:hover { + color: var(--highlight); +} + kbd { background-color: var(--secondary); border: 1px solid var(--border); @@ -997,10 +1007,22 @@ body.has-page-background .track-item:hover { max-width: 800px; } +.settings-group { + border-bottom: 1px solid var(--border); + padding: var(--spacing-lg) 0; + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.settings-group .setting-item { + border-bottom: none; + padding: 0; +} + .setting-item { display: flex; justify-content: space-between; - align-items: center; padding: var(--spacing-lg) 0; border-bottom: 1px solid var(--border); gap: var(--spacing-lg); @@ -3200,4 +3222,44 @@ img:not([src]), img[src=''] { filter: brightness(1.1); } +/* Firebase Settings Styling */ +.firebase-settings-wrapper { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + max-width: 400px; + align-items: flex-end; +} + +.custom-firebase-config { + display: none; + flex-direction: column; + gap: 0.5rem; + margin-top: 0.5rem; + width: 100%; +} + +.custom-firebase-config.visible { + display: flex; +} + +.config-help-text { + font-size: 0.8rem; + opacity: 0.7; + margin-bottom: 0.25rem; +} + +#firebase-controls { + text-align: end; +} +.firebase-controls-container { + display: flex; + gap: 0.5rem; + width: 100%; +} + +#toggle-firebase-config-btn { + width: fit-content; +}