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
This commit is contained in:
parent
2995000ffb
commit
2a98654e54
9 changed files with 678 additions and 40 deletions
74
firebase-setup.md
Normal file
74
firebase-setup.md
Normal file
|
|
@ -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.
|
||||
30
index.html
30
index.html
|
|
@ -336,6 +336,36 @@
|
|||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Firebase Configuration</span>
|
||||
<span class="description">Paste your Firebase Config JSON here to enable sync.</span>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%; max-width: 400px;">
|
||||
<textarea id="firebase-config-input" class="template-input" rows="5" placeholder='{ "apiKey": "...", "authDomain": "...", ... }'></textarea>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<button id="save-firebase-config-btn" class="btn-secondary">Save & Reload</button>
|
||||
<button id="share-firebase-config-btn" class="btn-secondary">Share</button>
|
||||
<button id="clear-firebase-config-btn" class="btn-secondary danger">Clear Config</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Sync & Backup (Beta)</span>
|
||||
<span class="description" id="firebase-status">Sync your library across devices</span>
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem; align-items: center;">
|
||||
<img id="firebase-user-avatar" src="" style="width: 24px; height: 24px; border-radius: 50%; display: none;" onerror="this.style.display='none'" onload="this.style.display='block'">
|
||||
<span id="firebase-user-name" style="font-size: 0.85rem; color: var(--foreground);"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="firebase-controls">
|
||||
<button id="firebase-connect-btn" class="btn-secondary">Connect with Google</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item">
|
||||
<div class="info">
|
||||
<span class="label">Audio Quality</span>
|
||||
|
|
|
|||
16
js/app.js
16
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() {
|
||||
|
|
|
|||
49
js/db.js
49
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);
|
||||
}
|
||||
|
|
|
|||
20
js/events.js
20
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);
|
||||
|
|
|
|||
92
js/firebase/auth.js
Normal file
92
js/firebase/auth.js
Normal file
|
|
@ -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();
|
||||
203
js/firebase/config.js
Normal file
203
js/firebase/config.js
Normal file
|
|
@ -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 };
|
||||
227
js/firebase/sync.js
Normal file
227
js/firebase/sync.js
Normal file
|
|
@ -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();
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue