From 24f5dedcfe6bf35b1f40d8b2ef8d515564de03d4 Mon Sep 17 00:00:00 2001 From: Samidy Date: Tue, 13 Jan 2026 23:25:51 +0300 Subject: [PATCH] I HATE FIREBASE --- firebase-setup.md | 1 + index.html | 22 ++++ js/api.js | 4 + js/app.js | 62 +++++++++++ js/db.js | 113 ++++++++++++++----- js/events.js | 3 + js/firebase/sync.js | 107 +++++++++++------- js/metadata.js | 265 ++++++++++++++++++++++++++++++++++++++++++++ js/player.js | 48 ++++---- js/ui.js | 75 +++++++++++-- 10 files changed, 603 insertions(+), 97 deletions(-) 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} Track metadata + */ +export async function readTrackMetadata(file) { + const metadata = { + title: file.name.replace(/\.[^/.]+$/, ""), + artists: [], + artist: { name: 'Unknown Artist' }, // For fallback/compatibility + album: { title: 'Unknown Album', cover: 'assets/appicon.png', releaseDate: null }, + duration: 0, + isLocal: true, + file: file, + id: `local-${file.name}-${file.lastModified}` + }; + + try { + if (file.type === 'audio/flac' || file.name.endsWith('.flac')) { + await readFlacMetadata(file, metadata); + } else if (file.type === 'audio/mp4' || file.name.endsWith('.m4a')) { + await readM4aMetadata(file, metadata); + } else if (file.type === 'audio/mpeg' || file.name.endsWith('.mp3')) { + await readMp3Metadata(file, metadata); + } + } catch (e) { + console.warn('Error reading metadata for', file.name, e); + } + + if (metadata.artists.length > 0) { + metadata.artist = metadata.artists[0]; + } + + return metadata; +} + +async function readFlacMetadata(file, metadata) { + const arrayBuffer = await file.arrayBuffer(); + const dataView = new DataView(arrayBuffer); + + if (!isFlacFile(dataView)) return; + + const blocks = parseFlacBlocks(dataView); + const vorbisBlock = blocks.find(b => b.type === 4); + + const artists = []; + if (vorbisBlock) { + const offset = vorbisBlock.offset; + const vendorLen = dataView.getUint32(offset, true); + let pos = offset + 4 + vendorLen; + const commentListLen = dataView.getUint32(pos, true); + pos += 4; + + for (let i = 0; i < commentListLen; i++) { + const len = dataView.getUint32(pos, true); + pos += 4; + const comment = new TextDecoder().decode(new Uint8Array(arrayBuffer, pos, len)); + pos += len; + + const eqIdx = comment.indexOf('='); + if (eqIdx > -1) { + const key = comment.substring(0, eqIdx); + const value = comment.substring(eqIdx + 1); + const upperKey = key.toUpperCase(); + if (upperKey === 'TITLE') metadata.title = value; + if (upperKey === 'ARTIST' || upperKey === 'ALBUMARTIST') { + artists.push(value); + } + if (upperKey === 'ALBUM') metadata.album.title = value; + } + } + } + + if (artists.length > 0) { + metadata.artists = artists.flatMap(a => a.split(/; |\/|\\/)).map(name => ({ name: name.trim() })); + } +} + +async function readM4aMetadata(file, metadata) { + try { + const chunkSize = Math.min(file.size, 5 * 1024 * 1024); + const buffer = await file.slice(0, chunkSize).arrayBuffer(); + const view = new DataView(buffer); + + const atoms = parseMp4Atoms(view); + + + const moov = atoms.find(a => a.type === 'moov'); + if (!moov) return; + + const moovStart = moov.offset + 8; + const moovLen = moov.size - 8; + const moovData = new DataView(view.buffer, moovStart, moovLen); + const moovAtoms = parseMp4Atoms(moovData); + + const udta = moovAtoms.find(a => a.type === 'udta'); + if (!udta) return; + + const udtaStart = moovStart + udta.offset + 8; + const udtaLen = udta.size - 8; + const udtaData = new DataView(view.buffer, udtaStart, udtaLen); + const udtaAtoms = parseMp4Atoms(udtaData); + + const meta = udtaAtoms.find(a => a.type === 'meta'); + if (!meta) return; + + const metaStart = udtaStart + meta.offset + 12; + const metaLen = meta.size - 12; + const metaData = new DataView(view.buffer, metaStart, metaLen); + const metaAtoms = parseMp4Atoms(metaData); + + const ilst = metaAtoms.find(a => a.type === 'ilst'); + if (!ilst) return; + + const ilstStart = metaStart + ilst.offset + 8; + const ilstLen = ilst.size - 8; + const ilstData = new DataView(view.buffer, ilstStart, ilstLen); + const items = parseMp4Atoms(ilstData); + + let artistStr = null; + + for (const item of items) { + const itemStart = ilstStart + item.offset + 8; + const itemLen = item.size - 8; + const itemData = new DataView(view.buffer, itemStart, itemLen); + const dataAtom = parseMp4Atoms(itemData).find(a => a.type === 'data'); + if (dataAtom) { + const contentLen = dataAtom.size - 16; + const contentOffset = itemStart + dataAtom.offset + 16; + + if (item.type === '©nam') { + metadata.title = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen)); + } else if (item.type === '©ART') { + artistStr = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen)); + } else if (item.type === '©alb') { + metadata.album.title = new TextDecoder().decode(new Uint8Array(view.buffer, contentOffset, contentLen)); + } + } + } + + if (artistStr) { + metadata.artists = artistStr.split(/; |\/|\\/).map(name => ({ name: name.trim() })); + } + + } catch (e) { + console.warn('Error parsing M4A:', e); + } +} + +async function readMp3Metadata(file, metadata) { + let buffer = await file.slice(0, 10).arrayBuffer(); + let view = new DataView(buffer); + + if (view.getUint8(0) === 0x49 && view.getUint8(1) === 0x44 && view.getUint8(2) === 0x33) { + const majorVer = view.getUint8(3); + const size = readSynchsafeInteger32(view, 6); + const tagSize = size + 10; + + buffer = await file.slice(0, tagSize).arrayBuffer(); + view = new DataView(buffer); + + let offset = 10; + if ((view.getUint8(5) & 0x40) !== 0) { + const extSize = readSynchsafeInteger32(view, offset); + offset += extSize; + } + + let tpe1 = null; + let tpe2 = null; + while (offset < view.byteLength) { + let frameId, frameSize; + + if (majorVer === 3) { + frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4)); + frameSize = view.getUint32(offset + 4, false); + offset += 10; + } else if (majorVer === 4) { + frameId = new TextDecoder().decode(new Uint8Array(buffer, offset, 4)); + frameSize = readSynchsafeInteger32(view, offset + 4); + offset += 10; + } else { + break; + } + + if (frameId.charCodeAt(0) === 0) break; + if (offset + frameSize > view.byteLength) break; + + const frameData = new DataView(buffer, offset, frameSize); + if (frameId === 'TIT2') metadata.title = readID3Text(frameData); + if (frameId === 'TPE1') tpe1 = readID3Text(frameData); + if (frameId === 'TPE2') tpe2 = readID3Text(frameData); + if (frameId === 'TALB') metadata.album.title = readID3Text(frameData); + if (frameId === 'TYER' || frameId === 'TDRC') { + const year = readID3Text(frameData); + if (year) metadata.album.releaseDate = year; + } + + offset += frameSize; + } + + const artistStr = tpe1 || tpe2; + if (artistStr) { + metadata.artists = artistStr.split('/').map(name => ({ name: name.trim() })); + } + } + + if (file.size > 128) { + const tailBuffer = await file.slice(file.size - 128).arrayBuffer(); + const tailView = new DataView(tailBuffer); + const tag = new TextDecoder().decode(new Uint8Array(tailBuffer, 0, 3)); + if (tag === 'TAG') { + const title = new TextDecoder().decode(new Uint8Array(tailBuffer, 3, 30)).replace(/\0/g, '').trim(); + const artist = new TextDecoder().decode(new Uint8Array(tailBuffer, 33, 30)).replace(/\0/g, '').trim(); + const album = new TextDecoder().decode(new Uint8Array(tailBuffer, 63, 30)).replace(/\0/g, '').trim(); + if (title) metadata.title = title; + if (artist && metadata.artists.length === 0) { + metadata.artists = [{ name: artist }]; + } + if (album) metadata.album.title = album; + } + } +} + +function readSynchsafeInteger32(view, offset) { + return ( + ((view.getUint8(offset) & 0x7f) << 21) | + ((view.getUint8(offset + 1) & 0x7f) << 14) | + ((view.getUint8(offset + 2) & 0x7f) << 7) | + (view.getUint8(offset + 3) & 0x7f) + ); +} + +function readID3Text(view) { + const encoding = view.getUint8(0); + const buffer = view.buffer.slice(view.byteOffset + 1, view.byteOffset + view.byteLength); + let decoder; + if (encoding === 0) decoder = new TextDecoder('iso-8859-1'); + else if (encoding === 1) decoder = new TextDecoder('utf-16'); + else if (encoding === 2) decoder = new TextDecoder('utf-16be'); + else decoder = new TextDecoder('utf-8'); + + return decoder.decode(buffer).replace(/\0/g, ''); +} + +function readID3Picture(view) { + let offset = 1; + const encoding = view.getUint8(0); + + let mimeEnd = offset; + while (view.getUint8(mimeEnd) !== 0) mimeEnd++; + const mime = new TextDecoder('iso-8859-1').decode(view.buffer.slice(view.byteOffset + offset, view.byteOffset + mimeEnd)); + offset = mimeEnd + 1; + + const picType = view.getUint8(offset); + offset++; + + let descEnd = offset; + while (view.getUint8(descEnd) !== 0 || (encoding === 1 || encoding === 2 ? view.getUint8(descEnd+1) !== 0 : false)) descEnd++; + offset = descEnd + (encoding === 1 || encoding === 2 ? 2 : 1); + + const imgData = view.buffer.slice(view.byteOffset + offset, view.byteOffset + view.byteLength); + const blob = new Blob([imgData], { type: mime }); + return URL.createObjectURL(blob); +} + /** * Adds Vorbis comment metadata to FLAC files */ diff --git a/js/player.js b/js/player.js index c1954f9..69a9dd3 100644 --- a/js/player.js +++ b/js/player.js @@ -200,7 +200,7 @@ export class Player { for (const { track } of tracksToPreload) { if (this.preloadCache.has(track.id)) continue; - const trackTitle = getTrackTitle(track); + if (track.isLocal) continue; try { const streamUrl = await this.api.getStreamUrl(track.id, this.quality); @@ -253,29 +253,35 @@ export class Player { this.updatePlayingTrackIndicator(); try { - // Get track data for ReplayGain (should be cached by API) - const trackData = await this.api.getTrack(track.id, this.quality); - - if (trackData && trackData.info) { - this.currentRgValues = { - trackReplayGain: trackData.info.trackReplayGain, - trackPeakAmplitude: trackData.info.trackPeakAmplitude, - albumReplayGain: trackData.info.albumReplayGain, - albumPeakAmplitude: trackData.info.albumPeakAmplitude, - }; - } else { - this.currentRgValues = null; - } - this.applyReplayGain(); - let streamUrl; - if (this.preloadCache.has(track.id)) { - streamUrl = this.preloadCache.get(track.id); - } else if (trackData.originalTrackUrl) { - streamUrl = trackData.originalTrackUrl; + if (track.isLocal && track.file) { + streamUrl = URL.createObjectURL(track.file); + this.currentRgValues = null; // No replaygain for local files yet + this.applyReplayGain(); } else { - streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest); + // Get track data for ReplayGain (should be cached by API) + const trackData = await this.api.getTrack(track.id, this.quality); + + if (trackData && trackData.info) { + this.currentRgValues = { + trackReplayGain: trackData.info.trackReplayGain, + trackPeakAmplitude: trackData.info.trackPeakAmplitude, + albumReplayGain: trackData.info.albumReplayGain, + albumPeakAmplitude: trackData.info.albumPeakAmplitude, + }; + } else { + this.currentRgValues = null; + } + this.applyReplayGain(); + + if (this.preloadCache.has(track.id)) { + streamUrl = this.preloadCache.get(track.id); + } else if (trackData.originalTrackUrl) { + streamUrl = trackData.originalTrackUrl; + } else { + streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest); + } } this.audio.src = streamUrl; diff --git a/js/ui.js b/js/ui.js index 287a048..a2b1acb 100644 --- a/js/ui.js +++ b/js/ui.js @@ -100,13 +100,28 @@ export class UIRenderer { const lyricsBtn = document.getElementById('toggle-lyrics-btn'); if (track) { + const isLocal = track.isLocal; + if (likeBtn) { - likeBtn.style.display = 'flex'; - this.updateLikeState(likeBtn.parentElement, 'track', track.id); + if (isLocal) { + likeBtn.style.display = 'none'; + } else { + likeBtn.style.display = 'flex'; + this.updateLikeState(likeBtn.parentElement, 'track', track.id); + } + } + if (addPlaylistBtn) { + if (isLocal) addPlaylistBtn.style.setProperty('display', 'none', 'important'); + else addPlaylistBtn.style.removeProperty('display'); + } + if (mobileAddPlaylistBtn) { + if (isLocal) mobileAddPlaylistBtn.style.setProperty('display', 'none', 'important'); + else mobileAddPlaylistBtn.style.removeProperty('display'); + } + if (lyricsBtn) { + if (isLocal) lyricsBtn.style.display = 'none'; + else lyricsBtn.style.removeProperty('display'); } - if (addPlaylistBtn) addPlaylistBtn.style.removeProperty('display'); - if (mobileAddPlaylistBtn) mobileAddPlaylistBtn.style.removeProperty('display'); - if (lyricsBtn) lyricsBtn.style.removeProperty('display'); } else { if (likeBtn) likeBtn.style.display = 'none'; if (addPlaylistBtn) addPlaylistBtn.style.setProperty('display', 'none', 'important'); @@ -165,6 +180,10 @@ export class UIRenderer { const trackTitle = getTrackTitle(track); const isCurrentTrack = this.player?.currentTrack?.id === track.id; + if (track.isLocal) { + showCover = false; + } + let yearDisplay = ''; const releaseDate = track.album?.releaseDate || track.streamStartDate; if (releaseDate) { @@ -197,17 +216,17 @@ export class UIRenderer { - - `; return ` -
+
${trackNumberHTML}
@@ -218,7 +237,7 @@ export class UIRenderer {
${escapeHtml(trackArtists)}${yearDisplay}
-
${formatTime(track.duration)}
+
${track.duration ? formatTime(track.duration) : '--:--'}
${actionsHTML}
@@ -681,6 +700,7 @@ export class UIRenderer { const albumsContainer = document.getElementById('library-albums-container'); const artistsContainer = document.getElementById('library-artists-container'); const playlistsContainer = document.getElementById('library-playlists-container'); + const localContainer = document.getElementById('library-local-container'); const likedTracks = await db.getFavorites('track'); if (likedTracks.length) { @@ -766,6 +786,43 @@ export class UIRenderer { } else { myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!'); } + + // Render Local Files + this.renderLocalFiles(localContainer); + } + + async renderLocalFiles(container) { + if (!container) return; + + const introDiv = document.getElementById('local-files-intro'); + const headerDiv = document.getElementById('local-files-header'); + const listContainer = document.getElementById('local-files-list'); + const selectBtnText = document.getElementById('select-local-folder-text'); + + const handle = await db.getSetting('local_folder_handle'); + if (handle) { + if (selectBtnText) selectBtnText.textContent = `Load "${handle.name}"`; + + if (window.localFilesCache && window.localFilesCache.length > 0) { + if (introDiv) introDiv.style.display = 'none'; + if (headerDiv) { + headerDiv.style.display = 'flex'; + headerDiv.querySelector('h3').textContent = `Local Files (${window.localFilesCache.length})`; + } + if (listContainer) { + this.renderListWithTracks(listContainer, window.localFilesCache, false); + } + } else { + if (introDiv) introDiv.style.display = 'block'; + if (headerDiv) headerDiv.style.display = 'none'; + if (listContainer) listContainer.innerHTML = ''; + } + } else { + if (selectBtnText) selectBtnText.textContent = 'Select Music Folder'; + if (introDiv) introDiv.style.display = 'block'; + if (headerDiv) headerDiv.style.display = 'none'; + if (listContainer) listContainer.innerHTML = ''; + } } async renderHomePage() {