I HATE FIREBASE
This commit is contained in:
parent
c752941b68
commit
24f5dedcfe
10 changed files with 603 additions and 97 deletions
|
|
@ -50,6 +50,7 @@ Firebase will block login attempts from unknown domains.
|
||||||
},
|
},
|
||||||
"public_playlists": {
|
"public_playlists": {
|
||||||
".read": true,
|
".read": true,
|
||||||
|
".indexOn": ["uid", "name"],
|
||||||
"$playlistId": {
|
"$playlistId": {
|
||||||
".write": "auth != null && (!data.exists() || data.child('uid').val() === auth.uid)"
|
".write": "auth != null && (!data.exists() || data.child('uid').val() === auth.uid)"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
22
index.html
22
index.html
|
|
@ -642,6 +642,7 @@
|
||||||
<button class="search-tab" data-tab="albums">Albums</button>
|
<button class="search-tab" data-tab="albums">Albums</button>
|
||||||
<button class="search-tab" data-tab="artists">Artists</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="playlists">Playlists and Mixes</button>
|
||||||
|
<button class="search-tab" data-tab="local">Local Files</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="search-tab-content active" id="library-tab-tracks">
|
<div class="search-tab-content active" id="library-tab-tracks">
|
||||||
<div class="track-list" id="library-tracks-container"></div>
|
<div class="track-list" id="library-tracks-container"></div>
|
||||||
|
|
@ -655,6 +656,27 @@
|
||||||
<div class="search-tab-content" id="library-tab-playlists">
|
<div class="search-tab-content" id="library-tab-playlists">
|
||||||
<div class="card-grid" id="library-playlists-container"></div>
|
<div class="card-grid" id="library-playlists-container"></div>
|
||||||
</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>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -937,6 +937,10 @@ export class LosslessAPI {
|
||||||
return `https://picsum.photos/seed/${Math.random()}/${size}`;
|
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, '/');
|
const formattedId = id.replace(/-/g, '/');
|
||||||
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
js/app.js
62
js/app.js
|
|
@ -28,6 +28,7 @@ import { db } from './db.js';
|
||||||
import { syncManager } from './firebase/sync.js';
|
import { syncManager } from './firebase/sync.js';
|
||||||
import { registerSW } from 'virtual:pwa-register';
|
import { registerSW } from 'virtual:pwa-register';
|
||||||
import './smooth-scrolling.js';
|
import './smooth-scrolling.js';
|
||||||
|
import { readTrackMetadata } from './metadata.js';
|
||||||
|
|
||||||
function initializeCasting(audioPlayer, castBtn) {
|
function initializeCasting(audioPlayer, castBtn) {
|
||||||
if (!castBtn) return;
|
if (!castBtn) return;
|
||||||
|
|
@ -817,6 +818,67 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
alert('Failed to load artist: ' + error.message);
|
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');
|
const searchForm = document.getElementById('search-form');
|
||||||
|
|
|
||||||
113
js/db.js
113
js/db.js
|
|
@ -1,7 +1,7 @@
|
||||||
export class MusicDatabase {
|
export class MusicDatabase {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.dbName = 'MonochromeDB';
|
this.dbName = 'MonochromeDB';
|
||||||
this.version = 5;
|
this.version = 6;
|
||||||
this.db = null;
|
this.db = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +53,9 @@ export class MusicDatabase {
|
||||||
const store = db.createObjectStore('user_playlists', { keyPath: 'id' });
|
const store = db.createObjectStore('user_playlists', { keyPath: 'id' });
|
||||||
store.createIndex('createdAt', 'createdAt', { unique: false });
|
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) {
|
async importData(data, clear = false) {
|
||||||
// Let's merge by put (replaces if ID exists).
|
|
||||||
const db = await this.open();
|
const db = await this.open();
|
||||||
|
|
||||||
const importStore = async (storeName, items) => {
|
const importStore = async (storeName, items) => {
|
||||||
// If items is undefined, we skip this store (don't clear, don't update)
|
if (items === undefined) return false;
|
||||||
// This allows partial updates (e.g. library only)
|
|
||||||
if (items === undefined) return;
|
|
||||||
|
|
||||||
let itemsArray = Array.isArray(items) ? items : Object.values(items || {});
|
let itemsArray = Array.isArray(items) ? items : Object.values(items || {});
|
||||||
|
if (itemsArray.length === 0) {
|
||||||
const transaction = db.transaction(storeName, 'readwrite');
|
if (clear) {
|
||||||
const store = transaction.objectStore(storeName);
|
return new Promise((resolve) => {
|
||||||
if (clear) {
|
const transaction = db.transaction(storeName, 'readwrite');
|
||||||
store.clear();
|
const store = transaction.objectStore(storeName);
|
||||||
}
|
const countReq = store.count();
|
||||||
|
countReq.onsuccess = () => {
|
||||||
for (const item of itemsArray) {
|
if (countReq.result > 0) {
|
||||||
try {
|
store.clear();
|
||||||
store.put(item);
|
resolve(true);
|
||||||
} catch (error) {
|
} else {
|
||||||
console.warn(`Failed to import item in ${storeName}:`, item, error);
|
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);
|
const results = await Promise.all([
|
||||||
await importStore('favorites_albums', data.favorites_albums);
|
importStore('favorites_tracks', data.favorites_tracks),
|
||||||
await importStore('favorites_artists', data.favorites_artists);
|
importStore('favorites_albums', data.favorites_albums),
|
||||||
await importStore('favorites_playlists', data.favorites_playlists);
|
importStore('favorites_artists', data.favorites_artists),
|
||||||
await importStore('favorites_mixes', data.favorites_mixes);
|
importStore('favorites_playlists', data.favorites_playlists),
|
||||||
await importStore('history_tracks', data.history_tracks);
|
importStore('favorites_mixes', data.favorites_mixes),
|
||||||
if (data.user_playlists) {
|
importStore('history_tracks', data.history_tracks),
|
||||||
await importStore('user_playlists', data.user_playlists);
|
data.user_playlists ? importStore('user_playlists', data.user_playlists) : Promise.resolve(false),
|
||||||
}
|
]);
|
||||||
|
|
||||||
|
return results.some((r) => r);
|
||||||
}
|
}
|
||||||
|
|
||||||
_updatePlaylistMetadata(playlist) {
|
_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();
|
export const db = new MusicDatabase();
|
||||||
|
|
|
||||||
|
|
@ -799,6 +799,8 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
if (trackItem && !trackItem.dataset.queueIndex) {
|
if (trackItem && !trackItem.dataset.queueIndex) {
|
||||||
const clickedTrack = trackDataStore.get(trackItem);
|
const clickedTrack = trackDataStore.get(trackItem);
|
||||||
|
|
||||||
|
if (clickedTrack && clickedTrack.isLocal) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
contextMenu.style.display === 'block' &&
|
contextMenu.style.display === 'block' &&
|
||||||
contextTrack &&
|
contextTrack &&
|
||||||
|
|
@ -866,6 +868,7 @@ export function initializeTrackInteractions(player, api, mainContent, contextMen
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contextTrack) {
|
if (contextTrack) {
|
||||||
|
if (contextTrack.isLocal) return;
|
||||||
await updateContextMenuLikeState(contextMenu, contextTrack);
|
await updateContextMenuLikeState(contextMenu, contextTrack);
|
||||||
positionMenu(contextMenu, e.pageX, e.pageY);
|
positionMenu(contextMenu, e.pageX, e.pageY);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,14 +56,17 @@ export class SyncManager {
|
||||||
// 1. Fetch Cloud Data
|
// 1. Fetch Cloud Data
|
||||||
const snapshot = await get(this.userRef);
|
const snapshot = await get(this.userRef);
|
||||||
const cloudData = snapshot.val() || {};
|
const cloudData = snapshot.val() || {};
|
||||||
const deletedPlaylists = cloudData.deleted_playlists || {};
|
|
||||||
|
|
||||||
// 2. Fetch Local Data
|
// 2. Fetch Local Data
|
||||||
const localData = await db.exportData();
|
const localData = await db.exportData();
|
||||||
|
|
||||||
// Filter out deleted playlists from local data
|
// Filter out deleted playlists from local data
|
||||||
if (localData.user_playlists && Array.isArray(localData.user_playlists)) {
|
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)
|
// 3. Merge Data (Union Strategy)
|
||||||
|
|
@ -123,7 +126,9 @@ export class SyncManager {
|
||||||
const id = item[idKey] || key;
|
const id = item[idKey] || key;
|
||||||
const localItem = map.get(id);
|
const localItem = map.get(id);
|
||||||
|
|
||||||
if (localItem) {
|
if (item.deleted) {
|
||||||
|
map.delete(id);
|
||||||
|
} else if (localItem) {
|
||||||
const localTime = localItem.updatedAt || 0;
|
const localTime = localItem.updatedAt || 0;
|
||||||
const cloudTime = item.updatedAt || 0;
|
const cloudTime = item.updatedAt || 0;
|
||||||
|
|
||||||
|
|
@ -202,48 +207,58 @@ export class SyncManager {
|
||||||
if (!this.userRef || this.listenersSetup) return;
|
if (!this.userRef || this.listenersSetup) return;
|
||||||
this.listenersSetup = true;
|
this.listenersSetup = true;
|
||||||
|
|
||||||
// Listen for changes in library
|
const setupLibraryListener = (nodeName, storeName) => {
|
||||||
const libraryRef = child(this.userRef, 'library');
|
const nodeRef = child(this.userRef, `library/${nodeName}`);
|
||||||
|
|
||||||
const unsubLibrary = onValue(libraryRef, (snapshot) => {
|
const handleAddOrChange = (snapshot) => {
|
||||||
if (this.isSyncing) return;
|
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();
|
const handleRemove = (snapshot) => {
|
||||||
if (val) {
|
if (this.isSyncing) return;
|
||||||
const importData = {
|
const key = snapshot.key;
|
||||||
favorites_tracks: val.tracks ? Object.values(val.tracks) : [],
|
if (key) {
|
||||||
favorites_albums: val.albums ? Object.values(val.albums) : [],
|
db.performTransaction(storeName, 'readwrite', (store) => store.delete(key)).then(() => {
|
||||||
favorites_artists: val.artists ? Object.values(val.artists) : [],
|
window.dispatchEvent(new Event('library-changed'));
|
||||||
favorites_playlists: val.playlists ? Object.values(val.playlists) : [],
|
});
|
||||||
};
|
}
|
||||||
db.importData(importData, false).then(() => {
|
};
|
||||||
// Notify UI to refresh
|
|
||||||
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
|
// Listen for changes in history
|
||||||
const historyRef = child(this.userRef, 'history/recentTracks');
|
const historyRef = child(this.userRef, 'history/recentTracks');
|
||||||
|
|
||||||
const unsubHistory = onValue(historyRef, (snapshot) => {
|
const unsubHistoryAdd = onChildAdded(historyRef, (snapshot) => {
|
||||||
if (this.isSyncing) return;
|
if (this.isSyncing) return;
|
||||||
|
|
||||||
const val = snapshot.val();
|
const val = snapshot.val();
|
||||||
if (val) {
|
if (val) {
|
||||||
const importData = {
|
db.importData({ history_tracks: [val] }, false).then((changed) => {
|
||||||
history_tracks: Object.values(val),
|
if (changed) window.dispatchEvent(new Event('history-changed'));
|
||||||
};
|
|
||||||
db.importData(importData, true).then(() => {
|
|
||||||
// Notify UI to refresh
|
|
||||||
window.dispatchEvent(new Event('history-changed'));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.unsubscribeFunctions.push(() => off(historyRef, 'child_added', unsubHistoryAdd));
|
||||||
this.unsubscribeFunctions.push(() => off(historyRef, 'value', unsubHistory));
|
|
||||||
|
|
||||||
// Listen for changes in user playlists
|
// Listen for changes in user playlists
|
||||||
const userPlaylistsRef = child(this.userRef, 'user_playlists');
|
const userPlaylistsRef = child(this.userRef, 'user_playlists');
|
||||||
|
|
@ -252,6 +267,12 @@ export class SyncManager {
|
||||||
if (this.isSyncing) return;
|
if (this.isSyncing) return;
|
||||||
|
|
||||||
const val = snapshot.val();
|
const val = snapshot.val();
|
||||||
|
if (val && val.deleted) {
|
||||||
|
db.deletePlaylist(val.id).then(() => {
|
||||||
|
window.dispatchEvent(new Event('library-changed'));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (val) {
|
if (val) {
|
||||||
if (val.tracks && typeof val.tracks === 'object' && !Array.isArray(val.tracks)) {
|
if (val.tracks && typeof val.tracks === 'object' && !Array.isArray(val.tracks)) {
|
||||||
val.tracks = Object.values(val.tracks);
|
val.tracks = Object.values(val.tracks);
|
||||||
|
|
@ -260,9 +281,9 @@ export class SyncManager {
|
||||||
const importData = {
|
const importData = {
|
||||||
user_playlists: [val],
|
user_playlists: [val],
|
||||||
};
|
};
|
||||||
db.importData(importData, false).then(() => {
|
db.importData(importData, false).then((changed) => {
|
||||||
// Notify UI to refresh library
|
// 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}`);
|
const itemRef = child(this.userRef, `history/recentTracks/${track.timestamp}`);
|
||||||
try {
|
try {
|
||||||
await set(itemRef, this.sanitizeForFirebase(track));
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to sync history item:', error);
|
console.error('Failed to sync history item:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -348,14 +379,8 @@ export class SyncManager {
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
};
|
};
|
||||||
await set(itemRef, this.sanitizeForFirebase(dataToSync));
|
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') {
|
} else if (action === 'delete') {
|
||||||
await remove(itemRef);
|
await update(itemRef, { deleted: true, updatedAt: Date.now() });
|
||||||
// Add tombstone
|
|
||||||
const deletedRef = child(this.userRef, `deleted_playlists/${id}`);
|
|
||||||
await set(deletedRef, { timestamp: Date.now() });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
265
js/metadata.js
265
js/metadata.js
|
|
@ -26,6 +26,271 @@ export async function addMetadataToAudio(audioBlob, track, api, quality) {
|
||||||
return audioBlob;
|
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
|
* Adds Vorbis comment metadata to FLAC files
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
48
js/player.js
48
js/player.js
|
|
@ -200,7 +200,7 @@ export class Player {
|
||||||
|
|
||||||
for (const { track } of tracksToPreload) {
|
for (const { track } of tracksToPreload) {
|
||||||
if (this.preloadCache.has(track.id)) continue;
|
if (this.preloadCache.has(track.id)) continue;
|
||||||
const trackTitle = getTrackTitle(track);
|
if (track.isLocal) continue;
|
||||||
try {
|
try {
|
||||||
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
|
const streamUrl = await this.api.getStreamUrl(track.id, this.quality);
|
||||||
|
|
||||||
|
|
@ -253,29 +253,35 @@ export class Player {
|
||||||
this.updatePlayingTrackIndicator();
|
this.updatePlayingTrackIndicator();
|
||||||
|
|
||||||
try {
|
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;
|
let streamUrl;
|
||||||
|
|
||||||
if (this.preloadCache.has(track.id)) {
|
if (track.isLocal && track.file) {
|
||||||
streamUrl = this.preloadCache.get(track.id);
|
streamUrl = URL.createObjectURL(track.file);
|
||||||
} else if (trackData.originalTrackUrl) {
|
this.currentRgValues = null; // No replaygain for local files yet
|
||||||
streamUrl = trackData.originalTrackUrl;
|
this.applyReplayGain();
|
||||||
} else {
|
} 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;
|
this.audio.src = streamUrl;
|
||||||
|
|
|
||||||
75
js/ui.js
75
js/ui.js
|
|
@ -100,13 +100,28 @@ export class UIRenderer {
|
||||||
const lyricsBtn = document.getElementById('toggle-lyrics-btn');
|
const lyricsBtn = document.getElementById('toggle-lyrics-btn');
|
||||||
|
|
||||||
if (track) {
|
if (track) {
|
||||||
|
const isLocal = track.isLocal;
|
||||||
|
|
||||||
if (likeBtn) {
|
if (likeBtn) {
|
||||||
likeBtn.style.display = 'flex';
|
if (isLocal) {
|
||||||
this.updateLikeState(likeBtn.parentElement, 'track', track.id);
|
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 {
|
} else {
|
||||||
if (likeBtn) likeBtn.style.display = 'none';
|
if (likeBtn) likeBtn.style.display = 'none';
|
||||||
if (addPlaylistBtn) addPlaylistBtn.style.setProperty('display', 'none', 'important');
|
if (addPlaylistBtn) addPlaylistBtn.style.setProperty('display', 'none', 'important');
|
||||||
|
|
@ -165,6 +180,10 @@ export class UIRenderer {
|
||||||
const trackTitle = getTrackTitle(track);
|
const trackTitle = getTrackTitle(track);
|
||||||
const isCurrentTrack = this.player?.currentTrack?.id === track.id;
|
const isCurrentTrack = this.player?.currentTrack?.id === track.id;
|
||||||
|
|
||||||
|
if (track.isLocal) {
|
||||||
|
showCover = false;
|
||||||
|
}
|
||||||
|
|
||||||
let yearDisplay = '';
|
let yearDisplay = '';
|
||||||
const releaseDate = track.album?.releaseDate || track.streamStartDate;
|
const releaseDate = track.album?.releaseDate || track.streamStartDate;
|
||||||
if (releaseDate) {
|
if (releaseDate) {
|
||||||
|
|
@ -197,17 +216,17 @@ export class UIRenderer {
|
||||||
<path d="M19 15v6" />
|
<path d="M19 15v6" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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}
|
${SVG_DOWNLOAD}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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}
|
${SVG_MENU}
|
||||||
</button>
|
</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return `
|
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}
|
${trackNumberHTML}
|
||||||
<div class="track-item-info">
|
<div class="track-item-info">
|
||||||
<div class="track-item-details">
|
<div class="track-item-details">
|
||||||
|
|
@ -218,7 +237,7 @@ export class UIRenderer {
|
||||||
<div class="artist">${escapeHtml(trackArtists)}${yearDisplay}</div>
|
<div class="artist">${escapeHtml(trackArtists)}${yearDisplay}</div>
|
||||||
</div>
|
</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">
|
<div class="track-item-actions">
|
||||||
${actionsHTML}
|
${actionsHTML}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -681,6 +700,7 @@ export class UIRenderer {
|
||||||
const albumsContainer = document.getElementById('library-albums-container');
|
const albumsContainer = document.getElementById('library-albums-container');
|
||||||
const artistsContainer = document.getElementById('library-artists-container');
|
const artistsContainer = document.getElementById('library-artists-container');
|
||||||
const playlistsContainer = document.getElementById('library-playlists-container');
|
const playlistsContainer = document.getElementById('library-playlists-container');
|
||||||
|
const localContainer = document.getElementById('library-local-container');
|
||||||
|
|
||||||
const likedTracks = await db.getFavorites('track');
|
const likedTracks = await db.getFavorites('track');
|
||||||
if (likedTracks.length) {
|
if (likedTracks.length) {
|
||||||
|
|
@ -766,6 +786,43 @@ export class UIRenderer {
|
||||||
} else {
|
} else {
|
||||||
myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!');
|
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() {
|
async renderHomePage() {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue