Merge pull request #31 from JulienMaille/firebase

feat: implement firebase synchronization for library and history
This commit is contained in:
Julien 2025-12-29 23:03:42 +01:00 committed by GitHub
commit 14691da826
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 974 additions and 186 deletions

74
firebase-setup.md Normal file
View 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.

View file

@ -282,160 +282,212 @@
<div id="page-settings" class="page">
<h2 class="section-title">Settings</h2>
<div class="settings-list">
<div class="setting-item">
<div class="info">
<span class="label">Theme</span>
<span class="description">Choose your preferred color scheme</span>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Theme</span>
<span class="description">Choose your preferred color scheme</span>
</div>
</div>
<div class="theme-picker" id="theme-picker">
<div class="theme-option" data-theme="system">System</div>
<div class="theme-option" data-theme="light">Light</div>
<div class="theme-option" data-theme="dark">Dark</div>
<div class="theme-option" data-theme="monochrome">Black</div>
<div class="theme-option" data-theme="ocean">Ocean</div>
<div class="theme-option" data-theme="purple">Purple</div>
<div class="theme-option" data-theme="forest">Forest</div>
<div class="theme-option" data-theme="custom">Custom</div>
</div>
<div class="custom-theme-editor" id="custom-theme-editor">
<h4>Custom Theme</h4>
<div class="theme-color-grid" id="theme-color-grid"></div>
<div class="theme-actions">
<button class="btn-secondary" id="apply-custom-theme">Apply Theme</button>
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
</div>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Album Cover Background</span>
<span class="description">Use the album cover as a blurred background on album pages and as primary color</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="album-background-toggle">
<span class="slider"></span>
</label>
</div>
</div>
<div class="theme-picker" id="theme-picker">
<div class="theme-option" data-theme="system">System</div>
<div class="theme-option" data-theme="light">Light</div>
<div class="theme-option" data-theme="dark">Dark</div>
<div class="theme-option" data-theme="monochrome">Black</div>
<div class="theme-option" data-theme="ocean">Ocean</div>
<div class="theme-option" data-theme="purple">Purple</div>
<div class="theme-option" data-theme="forest">Forest</div>
<div class="theme-option" data-theme="custom">Custom</div>
</div>
<div class="custom-theme-editor" id="custom-theme-editor">
<h4>Custom Theme</h4>
<div class="theme-color-grid" id="theme-color-grid"></div>
<div class="theme-actions">
<button class="btn-secondary" id="apply-custom-theme">Apply Theme</button>
<button class="btn-secondary" id="reset-custom-theme">Reset</button>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Last.fm Scrobbling</span>
<span class="description" id="lastfm-status">Connect your Last.fm account to scrobble tracks</span>
</div>
<div id="lastfm-controls">
<button id="lastfm-connect-btn" class="btn-secondary">Connect Last.fm</button>
</div>
</div>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Album Cover Background</span>
<span class="description">Use the album cover as a blurred background on album pages and as primary color</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="album-background-toggle">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Last.fm Scrobbling</span>
<span class="description" id="lastfm-status">Connect your Last.fm account to scrobble tracks</span>
</div>
<div id="lastfm-controls">
<button id="lastfm-connect-btn" class="btn-secondary">Connect Last.fm</button>
<div class="setting-item" id="lastfm-toggle-setting" style="display: none;">
<div class="info">
<span class="label">Enable Scrobbling</span>
<span class="description">Automatically scrobble played tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="lastfm-toggle">
<span class="slider"></span>
</label>
</div>
</div>
<div class="setting-item" id="lastfm-toggle-setting" style="display: none;">
<div class="info">
<span class="label">Enable Scrobbling</span>
<span class="description">Automatically scrobble played tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="lastfm-toggle">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Audio Quality</span>
<span class="description">Quality for streaming and downloads</span>
</div>
<select id="quality-setting">
<option value="LOSSLESS">FLAC (Lossless)</option>
<option value="HIGH">AAC 320kbps</option>
<option value="LOW">AAC 96kbps</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Now Playing View Mode</span>
<span class="description">Choose what shows when you click the album art</span>
</div>
<select id="now-playing-mode">
<option value="album">Show Album</option>
<option value="cover">Enlarged Cover</option>
<option value="lyrics">Lyrics Panel</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Track List Actions</span>
<span class="description">Choose between a dropdown menu or inline buttons for track actions</span>
</div>
<select id="track-list-actions-mode">
<option value="dropdown">Dropdown Menu</option>
<option value="inline">Inline Buttons</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Download Lyrics</span>
<span class="description">Include .lrc files when downloading tracks/albums</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="download-lyrics-toggle">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Gapless Playback</span>
<span class="description">Play audio without interruption between tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox" checked>
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Filename Template</span>
<span class="description">Customize download filenames. Available: {trackNumber}, {artist}, {title}, {album}</span>
</div>
<input type="text" id="filename-template" class="template-input" placeholder="{trackNumber} - {artist} - {title}">
</div>
<div class="setting-item">
<div class="info">
<span class="label">ZIP Folder Template</span>
<span class="description">Customize album folder names. Available: {albumTitle}, {albumArtist}, {year}</span>
</div>
<input type="text" id="zip-folder-template" class="template-input" placeholder="{albumTitle} - {albumArtist} - monochrome.tf">
</div>
<div class="setting-item">
<div class="info">
<span class="label">Keyboard Shortcuts</span>
<span class="description">View available keyboard shortcuts</span>
</div>
<button id="show-shortcuts-btn" class="btn-secondary">Show Shortcuts</button>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Cache</span>
<span class="description" id="cache-info">Stores API responses to reduce requests</span>
</div>
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Backup & Restore</span>
<span class="description">Export or import your library and history as JSON</span>
</div>
<div style="display: flex; gap: 0.5rem;">
<button id="export-library-btn" class="btn-secondary">Export</button>
<button id="import-library-btn" class="btn-secondary">Import</button>
<input type="file" id="import-library-input" style="display: none;" accept=".json">
</div>
</div>
<div id="api-instance-manager">
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">API Instances</span>
<span class="description">Manage and prioritize API instances. Automatically sorted by speed.</span>
<span class="label">Firebase Configuration</span>
<span class="description">Manage your database connection.</span>
</div>
<div class="firebase-settings-wrapper">
<button id="toggle-firebase-config-btn" class="btn-secondary">Advanced: Custom Configuration</button>
<div id="custom-firebase-config-container" class="custom-firebase-config">
<p class="config-help-text">Default shared instance is active. <a href="firebase-setup.md" target="_blank" class="text-link">Override below only if needed.</a></p>
<textarea id="firebase-config-input" class="template-input" rows="5" placeholder='{ "apiKey": "...", "authDomain": "...", ... }'></textarea>
<div class="firebase-controls-container">
<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>
<button id="refresh-speed-test-btn" class="btn-secondary">Refresh Speed Test</button>
</div>
<ul id="api-instance-list"></ul>
<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>
<button id="firebase-clear-cloud-btn" class="btn-secondary danger" style="display: none; margin-top: 0.5rem;">Clear Cloud Data</button>
</div>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Audio Quality</span>
<span class="description">Quality for streaming and downloads</span>
</div>
<select id="quality-setting">
<option value="LOSSLESS">FLAC (Lossless)</option>
<option value="HIGH">AAC 320kbps</option>
<option value="LOW">AAC 96kbps</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Gapless Playback</span>
<span class="description">Play audio without interruption between tracks</span>
</div>
<label class="toggle-switch">
<input type="checkbox" checked>
<span class="slider"></span>
</label>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Now Playing View Mode</span>
<span class="description">Choose what shows when you click the album art</span>
</div>
<select id="now-playing-mode">
<option value="album">Show Album</option>
<option value="cover">Enlarged Cover</option>
<option value="lyrics">Lyrics Panel</option>
</select>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Track List Actions</span>
<span class="description">Choose between a dropdown menu or inline buttons for track actions</span>
</div>
<select id="track-list-actions-mode">
<option value="dropdown">Dropdown Menu</option>
<option value="inline">Inline Buttons</option>
</select>
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Download Lyrics</span>
<span class="description">Include .lrc files when downloading tracks/albums</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="download-lyrics-toggle">
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Filename Template</span>
<span class="description">Customize download filenames. Available: {trackNumber}, {artist}, {title}, {album}</span>
</div>
<input type="text" id="filename-template" class="template-input" placeholder="{trackNumber} - {artist} - {title}">
</div>
<div class="setting-item">
<div class="info">
<span class="label">ZIP Folder Template</span>
<span class="description">Customize album folder names. Available: {albumTitle}, {albumArtist}, {year}</span>
</div>
<input type="text" id="zip-folder-template" class="template-input" placeholder="{albumTitle} - {albumArtist} - monochrome.tf">
</div>
</div>
<div class="settings-group">
<div class="setting-item">
<div class="info">
<span class="label">Keyboard Shortcuts</span>
<span class="description">View available keyboard shortcuts</span>
</div>
<button id="show-shortcuts-btn" class="btn-secondary">Show Shortcuts</button>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Cache</span>
<span class="description" id="cache-info">Stores API responses to reduce requests</span>
</div>
<button id="clear-cache-btn" class="btn-secondary">Clear Cache</button>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Backup & Restore</span>
<span class="description">Export or import your library and history as JSON</span>
</div>
<div style="display: flex; gap: 0.5rem;">
<button id="export-library-btn" class="btn-secondary">Export</button>
<button id="import-library-btn" class="btn-secondary">Import</button>
<input type="file" id="import-library-input" style="display: none;" accept=".json">
</div>
</div>
<div id="api-instance-manager">
<div class="setting-item" style="padding-bottom: 1rem; border: none;">
<div class="info">
<span class="label">API Instances</span>
<span class="description">Manage and prioritize API instances. Automatically sorted by speed.</span>
</div>
<button id="refresh-speed-test-btn" class="btn-secondary">Refresh Speed Test</button>
</div>
<ul id="api-instance-list"></ul>
</div>
</div>
</div>
</div>

View file

@ -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() {

View file

@ -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);
}

View file

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

97
js/firebase/auth.js Normal file
View file

@ -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();

235
js/firebase/config.js Normal file
View file

@ -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 };

243
js/firebase/sync.js Normal file
View file

@ -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();

View file

@ -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();

View file

@ -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;
}