I HATE FIREBASE

This commit is contained in:
Samidy 2026-01-13 23:25:51 +03:00
parent c752941b68
commit 24f5dedcfe
10 changed files with 603 additions and 97 deletions

View file

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

View file

@ -642,6 +642,7 @@
<button class="search-tab" data-tab="albums">Albums</button>
<button class="search-tab" data-tab="artists">Artists</button>
<button class="search-tab" data-tab="playlists">Playlists and Mixes</button>
<button class="search-tab" data-tab="local">Local Files</button>
</div>
<div class="search-tab-content active" id="library-tab-tracks">
<div class="track-list" id="library-tracks-container"></div>
@ -655,6 +656,27 @@
<div class="search-tab-content" id="library-tab-playlists">
<div class="card-grid" id="library-playlists-container"></div>
</div>
<div class="search-tab-content" id="library-tab-local">
<div class="track-list" id="library-local-container">
<div id="local-files-intro" style="padding: 20px; text-align: center;">
<button id="select-local-folder-btn" class="btn-secondary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-right: 8px;">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
</svg>
<span id="select-local-folder-text">Select Music Folder</span>
</button>
<p style="margin-top: 10px; font-size: 0.9rem; color: var(--muted-foreground);">
Select a folder on your device to play local files. <br>
Note: Metadata reading is basic (FLAC/MP3 tags).
</p>
</div>
<div id="local-files-header" style="display: none; justify-content: space-between; align-items: center; padding: 10px 20px;">
<h3>Local Files</h3>
<button id="change-local-folder-btn" class="btn-secondary" style="font-size: 0.8rem; padding: 4px 8px;">Change Folder</button>
</div>
<div id="local-files-list"></div>
</div>
</div>
</section>
</div>

View file

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

View file

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

113
js/db.js
View file

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

View file

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

View file

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

View file

@ -26,6 +26,271 @@ export async function addMetadataToAudio(audioBlob, track, api, quality) {
return audioBlob;
}
/**
* Reads metadata from a file
* @param {File} file
* @returns {Promise<Object>} 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
*/

View file

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

View file

@ -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 {
<path d="M19 15v6" />
</svg>
</button>
<button class="track-action-btn" data-action="download" title="Download">
<button class="track-action-btn" data-action="download" title="Download" ${track.isLocal ? 'style="display:none"' : ''}>
${SVG_DOWNLOAD}
</button>
</div>
<button class="track-menu-btn" type="button" title="More options">
<button class="track-menu-btn" type="button" title="More options" ${track.isLocal ? 'style="display:none"' : ''}>
${SVG_MENU}
</button>
`;
return `
<div class="track-item ${isCurrentTrack ? 'playing' : ''}" data-track-id="${track.id}">
<div class="track-item ${isCurrentTrack ? 'playing' : ''}" data-track-id="${track.id}" ${track.isLocal ? 'data-is-local="true"' : ''}>
${trackNumberHTML}
<div class="track-item-info">
<div class="track-item-details">
@ -218,7 +237,7 @@ export class UIRenderer {
<div class="artist">${escapeHtml(trackArtists)}${yearDisplay}</div>
</div>
</div>
<div class="track-item-duration">${formatTime(track.duration)}</div>
<div class="track-item-duration">${track.duration ? formatTime(track.duration) : '--:--'}</div>
<div class="track-item-actions">
${actionsHTML}
</div>
@ -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() {