-
- Last.fm Scrobbling
- Connect your Last.fm account to scrobble tracks
-
-
-
Connect Last.fm
+
+
+
+ Enable Scrobbling
+ Automatically scrobble played tracks
+
+
+
+
+
-
-
- Enable Scrobbling
- Automatically scrobble played tracks
-
-
-
-
-
-
-
-
- Audio Quality
- Quality for streaming and downloads
-
-
- FLAC (Lossless)
- AAC 320kbps
- AAC 96kbps
-
-
-
-
- Now Playing View Mode
- Choose what shows when you click the album art
-
-
- Show Album
- Enlarged Cover
- Lyrics Panel
-
-
-
-
- Track List Actions
- Choose between a dropdown menu or inline buttons for track actions
-
-
- Dropdown Menu
- Inline Buttons
-
-
-
-
- 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
-
-
Show Shortcuts
-
-
-
- Cache
- Stores API responses to reduce requests
-
-
Clear Cache
-
-
-
- Backup & Restore
- Export or import your library and history as JSON
-
-
- Export
- Import
-
-
-
-
-
+
+
- API Instances
- Manage and prioritize API instances. Automatically sorted by speed.
+ Firebase Configuration
+ Manage your database connection.
+
+
+
Advanced: Custom Configuration
+
-
Refresh Speed Test
-
+
+
+
+
Sync & Backup (Beta)
+
Sync your library across devices
+
+
+
+
+
+
+ Connect with Google
+ Clear Cloud Data
+
+
+
+
+
+
+
+ Audio Quality
+ Quality for streaming and downloads
+
+
+ FLAC (Lossless)
+ AAC 320kbps
+ AAC 96kbps
+
+
+
+
+ Gapless Playback
+ Play audio without interruption between tracks
+
+
+
+
+
+
+
+
+
+
+
+ Now Playing View Mode
+ Choose what shows when you click the album art
+
+
+ Show Album
+ Enlarged Cover
+ Lyrics Panel
+
+
+
+
+ Track List Actions
+ Choose between a dropdown menu or inline buttons for track actions
+
+
+ Dropdown Menu
+ Inline Buttons
+
+
+
+
+
+
+
+ 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
+
+
Show Shortcuts
+
+
+
+ Cache
+ Stores API responses to reduce requests
+
+
Clear Cache
+
+
+
+ Backup & Restore
+ Export or import your library and history as JSON
+
+
+ Export
+ Import
+
+
+
+
+
+
+ API Instances
+ Manage and prioritize API instances. Automatically sorted by speed.
+
+
Refresh Speed Test
+
+
+
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..f7fca2c
--- /dev/null
+++ b/js/firebase/auth.js
@@ -0,0 +1,97 @@
+// 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 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');
+ 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 (clearDataBtn) clearDataBtn.style.display = 'block';
+
+ 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 (clearDataBtn) clearDataBtn.style.display = 'none';
+
+ 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..9134572
--- /dev/null
+++ b/js/firebase/config.js
@@ -0,0 +1,235 @@
+// 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';
+
+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);
+ 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 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 " + (storedConfig ? "saved" : "default") + " config");
+ } catch (error) {
+ console.error("Error initializing Firebase:", error);
+ }
+} else {
+ console.log("No Firebase config found.");
+}
+
+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');
+ 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) {
+ const currentConfig = localStorage.getItem(STORAGE_KEY);
+ 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;
+ }
+ }
+ }
+
+ // 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 remove the custom configuration? The app will revert to the shared default database.')) {
+ 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..2ea1513
--- /dev/null
+++ b/js/firebase/sync.js
@@ -0,0 +1,243 @@
+// 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) {
+ // 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);
+ }
+ }
+
+ 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);
+ }
+ }
+
+ 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 8f82efd..9074a97 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');
@@ -291,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;
+}