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 @@
+
+
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');