diff --git a/js/app.js b/js/app.js index eca40d4..66fe3cb 100644 --- a/js/app.js +++ b/js/app.js @@ -928,6 +928,13 @@ document.addEventListener('DOMContentLoaded', async () => { ui.renderLibraryPage(); } else if (hash === '#home' || hash === '') { ui.renderHomePage(); + } else if (hash.startsWith('#userplaylist/')) { + const playlistId = hash.split('/')[1]; + const content = document.querySelector('.main-content'); + const scroll = content ? content.scrollTop : 0; + ui.renderPlaylistPage(playlistId, 'user').then(() => { + if (content) content.scrollTop = scroll; + }); } }); window.addEventListener('history-changed', () => { diff --git a/js/db.js b/js/db.js index d0e61a0..9b691cb 100644 --- a/js/db.js +++ b/js/db.js @@ -168,27 +168,27 @@ export class MusicDatabase { if (type === 'track') { return { ...base, - title: item.title, - duration: item.duration, - explicit: item.explicit, + title: item.title || null, + duration: item.duration || null, + explicit: item.explicit || false, // Keep minimal artist info - artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null), - artists: item.artists?.map((a) => ({ id: a.id, name: a.name })) || [], + artist: item.artist || (item.artists && item.artists.length > 0 ? item.artists[0] : null) || null, + artists: item.artists?.map((a) => ({ id: a.id, name: a.name || null })) || [], // Keep minimal album info album: item.album ? { id: item.album.id, - title: item.album.title, - cover: item.album.cover, + title: item.album.title || null, + cover: item.album.cover || null, releaseDate: item.album.releaseDate || null, vibrantColor: item.album.vibrantColor || null, - artist: item.album.artist, - numberOfTracks: item.album.numberOfTracks, + artist: item.album.artist || null, + numberOfTracks: item.album.numberOfTracks || null, } : null, - copyright: item.copyright, - isrc: item.isrc, - trackNumber: item.trackNumber, + copyright: item.copyright || null, + isrc: item.isrc || null, + trackNumber: item.trackNumber || null, // Fallback date streamStartDate: item.streamStartDate || null, // Keep version if exists @@ -201,26 +201,26 @@ export class MusicDatabase { if (type === 'album') { return { ...base, - title: item.title, - cover: item.cover, + title: item.title || null, + cover: item.cover || null, releaseDate: item.releaseDate || null, - explicit: item.explicit, + explicit: item.explicit || false, // UI uses singular 'artist' artist: item.artist - ? { name: item.artist.name, id: item.artist.id } + ? { name: item.artist.name || null, id: item.artist.id } : item.artists?.[0] - ? { name: item.artists[0].name, id: item.artists[0].id } + ? { name: item.artists[0].name || null, id: item.artists[0].id } : null, // Keep type and track count for UI labels type: item.type || null, - numberOfTracks: item.numberOfTracks, + numberOfTracks: item.numberOfTracks || null, }; } if (type === 'artist') { return { ...base, - name: item.name, + name: item.name || null, picture: item.picture || item.image || null, // Handle both just in case }; } @@ -229,11 +229,11 @@ export class MusicDatabase { return { uuid: item.uuid || item.id, addedAt: item.addedAt || item.createdAt || null, - title: item.title || item.name, + title: item.title || item.name || null, // UI checks squareImage || image || uuid image: item.image || item.squareImage || item.cover || null, numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0), - user: item.user ? { name: item.user.name } : null, + user: item.user ? { name: item.user.name || null } : null, }; } @@ -260,7 +260,7 @@ export class MusicDatabase { const mixes = await this.getFavorites('mix'); const history = await this.getHistory(); - const userPlaylists = await this.getPlaylists(); + const userPlaylists = await this.getPlaylists(true); const data = { favorites_tracks: tracks.map((t) => this._minifyItem('track', t)), favorites_albums: albums.map((a) => this._minifyItem('album', a)), @@ -339,6 +339,7 @@ export class MusicDatabase { tracks: tracks.map((t) => this._minifyItem('track', t)), cover: cover, createdAt: Date.now(), + updatedAt: Date.now(), }; this._updatePlaylistMetadata(playlist); await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); @@ -352,6 +353,7 @@ export class MusicDatabase { const minifiedTrack = this._minifyItem('track', track); if (playlist.tracks.some((t) => t.id === track.id)) return; playlist.tracks.push(minifiedTrack); + playlist.updatedAt = Date.now(); this._updatePlaylistMetadata(playlist); await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); return playlist; @@ -362,6 +364,7 @@ export class MusicDatabase { if (!playlist) throw new Error('Playlist not found'); playlist.tracks = playlist.tracks || []; playlist.tracks = playlist.tracks.filter((t) => t.id !== trackId); + playlist.updatedAt = Date.now(); this._updatePlaylistMetadata(playlist); await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); return playlist; @@ -375,7 +378,7 @@ export class MusicDatabase { return await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId)); } - async getPlaylists() { + async getPlaylists(includeTracks = false) { const db = await this.open(); return new Promise((resolve, reject) => { const transaction = db.transaction('user_playlists', 'readwrite'); // Changed to readwrite for lazy migration @@ -408,6 +411,10 @@ export class MusicDatabase { } } + if (includeTracks) { + return playlist; + } + // Return lightweight copy without tracks // eslint-disable-next-line no-unused-vars const { tracks, ...minified } = playlist; @@ -423,6 +430,7 @@ export class MusicDatabase { const playlist = await this.performTransaction('user_playlists', 'readonly', (store) => store.get(playlistId)); if (!playlist) throw new Error('Playlist not found'); playlist.name = newName; + playlist.updatedAt = Date.now(); await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist)); return playlist; } @@ -441,6 +449,7 @@ export class MusicDatabase { return; } playlist.tracks = tracks; + playlist.updatedAt = Date.now(); this._updatePlaylistMetadata(playlist); const putRequest = store.put(playlist); putRequest.onsuccess = () => { diff --git a/js/events.js b/js/events.js index c01a321..314b0dc 100644 --- a/js/events.js +++ b/js/events.js @@ -659,7 +659,7 @@ export async function handleTrackAction( } } } else if (action === 'add-to-playlist') { - const playlists = await db.getPlaylists(); + const playlists = await db.getPlaylists(true); if (playlists.length === 0) { showNotification('No playlists yet. Create one first.'); return; @@ -675,7 +675,7 @@ export async function handleTrackAction( const playlistsWithTrack = new Set(); for (const playlist of playlists) { - if (playlist.tracks && playlist.tracks.some((track) => track.id === trackId)) { + if (playlist.tracks && playlist.tracks.some((track) => track.id == trackId)) { playlistsWithTrack.add(playlist.id); } } diff --git a/js/firebase/sync.js b/js/firebase/sync.js index c07d647..410f189 100644 --- a/js/firebase/sync.js +++ b/js/firebase/sync.js @@ -10,6 +10,9 @@ import { child, remove, runTransaction, + onChildAdded, + onChildChanged, + onChildRemoved, } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-database.js'; import { db } from '../db.js'; @@ -19,6 +22,7 @@ export class SyncManager { this.userRef = null; this.unsubscribeFunctions = []; this.isSyncing = false; + this.listenersSetup = false; } initialize(user) { @@ -38,6 +42,7 @@ export class SyncManager { } this.user = null; this.userRef = null; + this.listenersSetup = false; console.log('SyncManager disconnected'); } @@ -83,12 +88,10 @@ export class SyncManager { 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.setupListeners(); this.isSyncing = false; } } @@ -110,13 +113,48 @@ export class SyncManager { // Add/Overwrite with cloud items (Union Strategy) if (cloudItems) { + const processItem = (item, key) => { + if (!item || typeof item !== 'object') return; + + if (item.tracks && typeof item.tracks === 'object' && !Array.isArray(item.tracks)) { + item.tracks = Object.values(item.tracks); + } + + const id = item[idKey] || key; + const localItem = map.get(id); + + if (localItem) { + const localTime = localItem.updatedAt || 0; + const cloudTime = item.updatedAt || 0; + + + if (cloudTime > localTime) { + const localTracks = Array.isArray(localItem.tracks) ? localItem.tracks.length : 0; + const cloudTracks = Array.isArray(item.tracks) ? item.tracks.length : 0; + + if (localTracks > 0 && cloudTracks === 0) { + } else { + map.set(id, item); + } + } else if (cloudTime === localTime) { + const localTracks = Array.isArray(localItem.tracks) ? localItem.tracks.length : 0; + const cloudTracks = Array.isArray(item.tracks) ? item.tracks.length : 0; + if (cloudTracks >= localTracks) { + map.set(id, item); + } + } + } else { + map.set(id, item); + } + }; + if (Array.isArray(cloudItems)) { - cloudItems.forEach((item) => map.set(item[idKey], item)); + cloudItems.forEach((item) => processItem(item)); } else { Object.keys(cloudItems).forEach((key) => { const val = cloudItems[key]; if (typeof val === 'object') { - map.set(val[idKey] || key, val); + processItem(val, key); } }); } @@ -162,6 +200,9 @@ export class SyncManager { } setupListeners() { + if (!this.userRef || this.listenersSetup) return; + this.listenersSetup = true; + // Listen for changes in library const libraryRef = child(this.userRef, 'library'); @@ -208,22 +249,42 @@ export class SyncManager { // Listen for changes in user playlists const userPlaylistsRef = child(this.userRef, 'user_playlists'); - const unsubUserPlaylists = onValue(userPlaylistsRef, (snapshot) => { + const handlePlaylistUpdate = (snapshot) => { if (this.isSyncing) return; const val = snapshot.val(); if (val) { + if (val.tracks && typeof val.tracks === 'object' && !Array.isArray(val.tracks)) { + val.tracks = Object.values(val.tracks); + } + const importData = { - user_playlists: Object.values(val), + user_playlists: [val], }; - db.importData(importData, true).then(() => { + db.importData(importData, false).then(() => { // Notify UI to refresh library window.dispatchEvent(new Event('library-changed')); }); } + }; + + const unsubChildAdded = onChildAdded(userPlaylistsRef, handlePlaylistUpdate); + const unsubChildChanged = onChildChanged(userPlaylistsRef, handlePlaylistUpdate); + const unsubChildRemoved = onChildRemoved(userPlaylistsRef, (snapshot) => { + if (this.isSyncing) return; + const key = snapshot.key; + if (key) { + db.deletePlaylist(key).then(() => { + window.dispatchEvent(new Event('library-changed')); + }); + } }); - this.unsubscribeFunctions.push(() => off(userPlaylistsRef, 'value', unsubUserPlaylists)); + this.unsubscribeFunctions.push(() => { + off(userPlaylistsRef, 'child_added', unsubChildAdded); + off(userPlaylistsRef, 'child_changed', unsubChildChanged); + off(userPlaylistsRef, 'child_removed', unsubChildRemoved); + }); } // --- Public API for Broadcasters --- @@ -258,7 +319,7 @@ export class SyncManager { ...minified, addedAt: item.addedAt || minified.addedAt || Date.now(), }; - await set(itemRef, entry); + await set(itemRef, this.sanitizeForFirebase(entry)); } else { await remove(itemRef); } @@ -269,7 +330,7 @@ export class SyncManager { const itemRef = child(this.userRef, `history/recentTracks/${track.timestamp}`); try { - await set(itemRef, track); + await set(itemRef, this.sanitizeForFirebase(track)); } catch (error) { console.error('Failed to sync history item:', error); } @@ -283,7 +344,11 @@ export class SyncManager { const itemRef = child(this.userRef, path); if (action === 'create' || action === 'update') { - await set(itemRef, playlist); + const dataToSync = { + ...playlist, + 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); @@ -323,7 +388,7 @@ export class SyncManager { // Use a global 'public_playlists' node const publicRef = ref(database, `public_playlists/${playlistId}`); - await set(publicRef, publicData); + await set(publicRef, this.sanitizeForFirebase(publicData)); } async unpublishPlaylist(playlistId) { @@ -352,6 +417,25 @@ export class SyncManager { return null; } } + + sanitizeForFirebase(obj) { + if (obj === undefined) return null; + if (obj === null) return null; + if (typeof obj !== 'object') return obj; + if (Array.isArray(obj)) { + return obj.map((v) => this.sanitizeForFirebase(v)); + } + const newObj = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const val = this.sanitizeForFirebase(obj[key]); + if (val !== undefined) { + newObj[key] = val; + } + } + } + return newObj; + } } export const syncManager = new SyncManager();