diff --git a/firebase-setup.md b/firebase-setup.md
index c65ee67..115dd74 100644
--- a/firebase-setup.md
+++ b/firebase-setup.md
@@ -50,6 +50,7 @@ Firebase will block login attempts from unknown domains.
},
"public_playlists": {
".read": true,
+ ".indexOn": ["uid", "name"],
"$playlistId": {
".write": "auth != null && (!data.exists() || data.child('uid').val() === auth.uid)"
}
diff --git a/index.html b/index.html
index 2ef1de7..cee8c71 100644
--- a/index.html
+++ b/index.html
@@ -642,6 +642,7 @@
+
@@ -655,6 +656,27 @@
+
+
+
+
+
+ Select a folder on your device to play local files.
+ Note: Metadata reading is basic (FLAC/MP3 tags).
+
+
+
+
+
+
diff --git a/js/api.js b/js/api.js
index eb978ec..c681d27 100644
--- a/js/api.js
+++ b/js/api.js
@@ -937,6 +937,10 @@ export class LosslessAPI {
return `https://picsum.photos/seed/${Math.random()}/${size}`;
}
+ if (typeof id === 'string' && (id.startsWith('blob:') || id.startsWith('assets/'))) {
+ return id;
+ }
+
const formattedId = id.replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
diff --git a/js/app.js b/js/app.js
index 66fe3cb..f6d1d60 100644
--- a/js/app.js
+++ b/js/app.js
@@ -28,6 +28,7 @@ import { db } from './db.js';
import { syncManager } from './firebase/sync.js';
import { registerSW } from 'virtual:pwa-register';
import './smooth-scrolling.js';
+import { readTrackMetadata } from './metadata.js';
function initializeCasting(audioPlayer, castBtn) {
if (!castBtn) return;
@@ -817,6 +818,67 @@ document.addEventListener('DOMContentLoaded', async () => {
alert('Failed to load artist: ' + error.message);
}
}
+
+ // Local Files Logic lollll
+ if (e.target.closest('#select-local-folder-btn') || e.target.closest('#change-local-folder-btn')) {
+ try {
+ const handle = await window.showDirectoryPicker({
+ id: 'music-folder',
+ mode: 'read'
+ });
+
+ await db.saveSetting('local_folder_handle', handle);
+
+ const btn = document.getElementById('select-local-folder-btn');
+ const btnText = document.getElementById('select-local-folder-text');
+ if (btn) {
+ if (btnText) btnText.textContent = 'Scanning...'; else btn.textContent = 'Scanning...';
+ btn.disabled = true;
+ }
+
+ const tracks = [];
+ let idCounter = 0;
+
+ async function scanDirectory(dirHandle) {
+ for await (const entry of dirHandle.values()) {
+ if (entry.kind === 'file') {
+ const name = entry.name.toLowerCase();
+ if (name.endsWith('.flac') || name.endsWith('.mp3') || name.endsWith('.m4a') || name.endsWith('.wav') || name.endsWith('.ogg')) {
+ const file = await entry.getFile();
+ const metadata = await readTrackMetadata(file);
+ metadata.id = `local-${idCounter++}-${file.name}`;
+ tracks.push(metadata);
+ }
+ } else if (entry.kind === 'directory') {
+ await scanDirectory(entry);
+ }
+ }
+ }
+
+ await scanDirectory(handle);
+
+ tracks.sort((a, b) => {
+ const artistA = a.artist.name || '';
+ const artistB = b.artist.name || '';
+ return artistA.localeCompare(artistB);
+ });
+
+ window.localFilesCache = tracks;
+ ui.renderLibraryPage();
+
+ } catch (err) {
+ if (err.name !== 'AbortError') {
+ console.error('Error selecting folder:', err);
+ alert('Failed to access folder. Please try again.');
+ }
+ const btn = document.getElementById('select-local-folder-btn');
+ const btnText = document.getElementById('select-local-folder-text');
+ if (btn) {
+ if (btnText) btnText.textContent = 'Select Music Folder'; else btn.textContent = 'Select Music Folder';
+ btn.disabled = false;
+ }
+ }
+ }
});
const searchForm = document.getElementById('search-form');
diff --git a/js/db.js b/js/db.js
index 9b691cb..aafc7e2 100644
--- a/js/db.js
+++ b/js/db.js
@@ -1,7 +1,7 @@
export class MusicDatabase {
constructor() {
this.dbName = 'MonochromeDB';
- this.version = 5;
+ this.version = 6;
this.db = null;
}
@@ -53,6 +53,9 @@ export class MusicDatabase {
const store = db.createObjectStore('user_playlists', { keyPath: 'id' });
store.createIndex('createdAt', 'createdAt', { unique: false });
}
+ if (!db.objectStoreNames.contains('settings')) {
+ db.createObjectStore('settings');
+ }
};
});
}
@@ -274,40 +277,90 @@ export class MusicDatabase {
}
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 is undefined, we skip this store (don't clear, don't update)
- // This allows partial updates (e.g. library only)
- if (items === undefined) return;
+ if (items === undefined) return false;
let itemsArray = Array.isArray(items) ? items : Object.values(items || {});
-
- const transaction = db.transaction(storeName, 'readwrite');
- const store = transaction.objectStore(storeName);
- if (clear) {
- store.clear();
- }
-
- for (const item of itemsArray) {
- try {
- store.put(item);
- } catch (error) {
- console.warn(`Failed to import item in ${storeName}:`, item, error);
+ if (itemsArray.length === 0) {
+ if (clear) {
+ return new Promise((resolve) => {
+ const transaction = db.transaction(storeName, 'readwrite');
+ const store = transaction.objectStore(storeName);
+ const countReq = store.count();
+ countReq.onsuccess = () => {
+ if (countReq.result > 0) {
+ store.clear();
+ resolve(true);
+ } else {
+ resolve(false);
+ }
+ };
+ countReq.onerror = () => resolve(false);
+ });
}
+ return false;
}
+
+ return new Promise((resolve, reject) => {
+ const transaction = db.transaction(storeName, 'readwrite');
+ const store = transaction.objectStore(storeName);
+ let hasChanges = false;
+
+ if (clear) {
+ store.clear();
+ hasChanges = true;
+ }
+
+ let pending = itemsArray.length;
+
+ itemsArray.forEach((item) => {
+ if (clear) {
+ store.put(item);
+ pending--;
+ if (pending === 0) resolve(true);
+ return;
+ }
+
+ let key;
+ if (storeName === 'favorites_playlists') key = item.uuid;
+ else if (storeName === 'history_tracks') key = item.timestamp;
+ else key = item.id;
+
+ const getReq = store.get(key);
+ getReq.onsuccess = () => {
+ const existing = getReq.result;
+ if (!existing || JSON.stringify(existing) !== JSON.stringify(item)) {
+ store.put(item);
+ hasChanges = true;
+ }
+ pending--;
+ if (pending === 0) resolve(hasChanges);
+ };
+ getReq.onerror = () => {
+ store.put(item);
+ hasChanges = true;
+ pending--;
+ if (pending === 0) resolve(hasChanges);
+ };
+ });
+
+ transaction.onerror = () => reject(transaction.error);
+ });
};
- await importStore('favorites_tracks', data.favorites_tracks);
- await importStore('favorites_albums', data.favorites_albums);
- await importStore('favorites_artists', data.favorites_artists);
- await importStore('favorites_playlists', data.favorites_playlists);
- await importStore('favorites_mixes', data.favorites_mixes);
- await importStore('history_tracks', data.history_tracks);
- if (data.user_playlists) {
- await importStore('user_playlists', data.user_playlists);
- }
+ const results = await Promise.all([
+ importStore('favorites_tracks', data.favorites_tracks),
+ importStore('favorites_albums', data.favorites_albums),
+ importStore('favorites_artists', data.favorites_artists),
+ importStore('favorites_playlists', data.favorites_playlists),
+ importStore('favorites_mixes', data.favorites_mixes),
+ importStore('history_tracks', data.history_tracks),
+ data.user_playlists ? importStore('user_playlists', data.user_playlists) : Promise.resolve(false),
+ ]);
+
+ return results.some((r) => r);
}
_updatePlaylistMetadata(playlist) {
@@ -468,6 +521,14 @@ export class MusicDatabase {
};
});
}
+
+ async saveSetting(key, value) {
+ await this.performTransaction('settings', 'readwrite', (store) => store.put(value, key));
+ }
+
+ async getSetting(key) {
+ return await this.performTransaction('settings', 'readonly', (store) => store.get(key));
+ }
}
export const db = new MusicDatabase();
diff --git a/js/events.js b/js/events.js
index 314b0dc..00bcba3 100644
--- a/js/events.js
+++ b/js/events.js
@@ -799,6 +799,8 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
if (trackItem && !trackItem.dataset.queueIndex) {
const clickedTrack = trackDataStore.get(trackItem);
+ if (clickedTrack && clickedTrack.isLocal) return;
+
if (
contextMenu.style.display === 'block' &&
contextTrack &&
@@ -866,6 +868,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
}
if (contextTrack) {
+ if (contextTrack.isLocal) return;
await updateContextMenuLikeState(contextMenu, contextTrack);
positionMenu(contextMenu, e.pageX, e.pageY);
}
diff --git a/js/firebase/sync.js b/js/firebase/sync.js
index 2a3ecc5..bbb89ff 100644
--- a/js/firebase/sync.js
+++ b/js/firebase/sync.js
@@ -56,14 +56,17 @@ export class SyncManager {
// 1. Fetch Cloud Data
const snapshot = await get(this.userRef);
const cloudData = snapshot.val() || {};
- const deletedPlaylists = cloudData.deleted_playlists || {};
// 2. Fetch Local Data
const localData = await db.exportData();
// Filter out deleted playlists from local data
if (localData.user_playlists && Array.isArray(localData.user_playlists)) {
- localData.user_playlists = localData.user_playlists.filter((p) => !deletedPlaylists[p.id]);
+ const cloudPlaylists = cloudData.user_playlists || {};
+ localData.user_playlists = localData.user_playlists.filter((p) => {
+ const cloudP = cloudPlaylists[p.id];
+ return !cloudP || !cloudP.deleted;
+ });
}
// 3. Merge Data (Union Strategy)
@@ -123,7 +126,9 @@ export class SyncManager {
const id = item[idKey] || key;
const localItem = map.get(id);
- if (localItem) {
+ if (item.deleted) {
+ map.delete(id);
+ } else if (localItem) {
const localTime = localItem.updatedAt || 0;
const cloudTime = item.updatedAt || 0;
@@ -202,48 +207,58 @@ export class SyncManager {
if (!this.userRef || this.listenersSetup) return;
this.listenersSetup = true;
- // Listen for changes in library
- const libraryRef = child(this.userRef, 'library');
+ const setupLibraryListener = (nodeName, storeName) => {
+ const nodeRef = child(this.userRef, `library/${nodeName}`);
- const unsubLibrary = onValue(libraryRef, (snapshot) => {
- if (this.isSyncing) return;
+ const handleAddOrChange = (snapshot) => {
+ if (this.isSyncing) return;
+ const val = snapshot.val();
+ if (val) {
+ const importData = {};
+ importData[storeName] = [val];
+ db.importData(importData, false).then((changed) => {
+ if (changed) window.dispatchEvent(new Event('library-changed'));
+ });
+ }
+ };
- 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, false).then(() => {
- // Notify UI to refresh
- window.dispatchEvent(new Event('library-changed'));
- });
- }
- });
+ const handleRemove = (snapshot) => {
+ if (this.isSyncing) return;
+ const key = snapshot.key;
+ if (key) {
+ db.performTransaction(storeName, 'readwrite', (store) => store.delete(key)).then(() => {
+ window.dispatchEvent(new Event('library-changed'));
+ });
+ }
+ };
- this.unsubscribeFunctions.push(() => off(libraryRef, 'value', unsubLibrary));
+ const unsubAdd = onChildAdded(nodeRef, handleAddOrChange);
+ const unsubChange = onChildChanged(nodeRef, handleAddOrChange);
+ const unsubRemove = onChildRemoved(nodeRef, handleRemove);
+
+ this.unsubscribeFunctions.push(() => off(nodeRef, 'child_added', unsubAdd));
+ this.unsubscribeFunctions.push(() => off(nodeRef, 'child_changed', unsubChange));
+ this.unsubscribeFunctions.push(() => off(nodeRef, 'child_removed', unsubRemove));
+ };
+
+ setupLibraryListener('tracks', 'favorites_tracks');
+ setupLibraryListener('albums', 'favorites_albums');
+ setupLibraryListener('artists', 'favorites_artists');
+ setupLibraryListener('playlists', 'favorites_playlists');
// Listen for changes in history
const historyRef = child(this.userRef, 'history/recentTracks');
- const unsubHistory = onValue(historyRef, (snapshot) => {
+ const unsubHistoryAdd = onChildAdded(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'));
+ db.importData({ history_tracks: [val] }, false).then((changed) => {
+ if (changed) window.dispatchEvent(new Event('history-changed'));
});
}
});
-
- this.unsubscribeFunctions.push(() => off(historyRef, 'value', unsubHistory));
+ this.unsubscribeFunctions.push(() => off(historyRef, 'child_added', unsubHistoryAdd));
// Listen for changes in user playlists
const userPlaylistsRef = child(this.userRef, 'user_playlists');
@@ -252,6 +267,12 @@ export class SyncManager {
if (this.isSyncing) return;
const val = snapshot.val();
+ if (val && val.deleted) {
+ db.deletePlaylist(val.id).then(() => {
+ window.dispatchEvent(new Event('library-changed'));
+ });
+ return;
+ }
if (val) {
if (val.tracks && typeof val.tracks === 'object' && !Array.isArray(val.tracks)) {
val.tracks = Object.values(val.tracks);
@@ -260,9 +281,9 @@ export class SyncManager {
const importData = {
user_playlists: [val],
};
- db.importData(importData, false).then(() => {
+ db.importData(importData, false).then((changed) => {
// Notify UI to refresh library
- window.dispatchEvent(new Event('library-changed'));
+ if (changed) window.dispatchEvent(new Event('library-changed'));
});
}
};
@@ -330,6 +351,16 @@ export class SyncManager {
const itemRef = child(this.userRef, `history/recentTracks/${track.timestamp}`);
try {
await set(itemRef, this.sanitizeForFirebase(track));
+
+ const localHistory = await db.getHistory();
+ if (localHistory.length > 50) {
+ const toRemove = localHistory.slice(50);
+ const updates = {};
+ toRemove.forEach((t) => {
+ updates[`history/recentTracks/${t.timestamp}`] = null;
+ });
+ await update(this.userRef, updates);
+ }
} catch (error) {
console.error('Failed to sync history item:', error);
}
@@ -348,14 +379,8 @@ export class SyncManager {
updatedAt: Date.now(),
};
await set(itemRef, this.sanitizeForFirebase(dataToSync));
- // Ensure it's not in deleted_playlists (just in case)
- const deletedRef = child(this.userRef, `deleted_playlists/${id}`);
- await remove(deletedRef);
} else if (action === 'delete') {
- await remove(itemRef);
- // Add tombstone
- const deletedRef = child(this.userRef, `deleted_playlists/${id}`);
- await set(deletedRef, { timestamp: Date.now() });
+ await update(itemRef, { deleted: true, updatedAt: Date.now() });
}
}
diff --git a/js/metadata.js b/js/metadata.js
index 5d246e5..4524d35 100644
--- a/js/metadata.js
+++ b/js/metadata.js
@@ -26,6 +26,271 @@ export async function addMetadataToAudio(audioBlob, track, api, quality) {
return audioBlob;
}
+/**
+ * Reads metadata from a file
+ * @param {File} file
+ * @returns {Promise