403 lines
14 KiB
JavaScript
403 lines
14 KiB
JavaScript
// MusicRoom - Playlist module
|
||
// Playlist CRUD, library rendering, playlist rendering
|
||
|
||
(function() {
|
||
const M = window.MusicRoom;
|
||
|
||
// Update cache status for all tracks
|
||
M.updateCacheStatus = async function() {
|
||
const cached = await TrackStorage.list();
|
||
|
||
// Migration: remove old filename-based cache entries (keep only sha256: prefixed)
|
||
const oldEntries = cached.filter(id => !id.startsWith("sha256:"));
|
||
if (oldEntries.length > 0) {
|
||
console.log("[Cache] Migrating: removing", oldEntries.length, "old filename-based entries");
|
||
for (const oldId of oldEntries) {
|
||
await TrackStorage.remove(oldId);
|
||
}
|
||
// Re-fetch after cleanup
|
||
const updated = await TrackStorage.list();
|
||
M.cachedTracks = new Set(updated);
|
||
} else {
|
||
M.cachedTracks = new Set(cached);
|
||
}
|
||
console.log("[Cache] Updated cache status:", M.cachedTracks.size, "tracks cached");
|
||
};
|
||
|
||
// Debug: log cache status for current track
|
||
M.debugCacheStatus = function() {
|
||
if (!M.currentTrackId) {
|
||
console.log("[Cache Debug] No current track");
|
||
return;
|
||
}
|
||
const trackCache = M.getTrackCache(M.currentTrackId);
|
||
const segmentsPct = Math.round((trackCache.size / M.SEGMENTS) * 100);
|
||
const inCachedTracks = M.cachedTracks.has(M.currentTrackId);
|
||
const hasBlobUrl = M.trackBlobs.has(M.currentTrackId);
|
||
const bulkStarted = M.bulkDownloadStarted.get(M.currentTrackId);
|
||
|
||
console.log("[Cache Debug]", {
|
||
trackId: M.currentTrackId.slice(0, 16) + "...",
|
||
segments: `${trackCache.size}/${M.SEGMENTS} (${segmentsPct}%)`,
|
||
inCachedTracks,
|
||
hasBlobUrl,
|
||
bulkStarted,
|
||
loadingSegments: [...M.loadingSegments],
|
||
cachedTracksSize: M.cachedTracks.size
|
||
});
|
||
};
|
||
|
||
// Debug: compare playlist track IDs with cached track IDs
|
||
M.debugCacheMismatch = function() {
|
||
console.log("[Cache Mismatch Debug]");
|
||
console.log("=== Raw State ===");
|
||
console.log("M.cachedTracks:", M.cachedTracks);
|
||
console.log("M.trackCaches:", M.trackCaches);
|
||
console.log("M.trackBlobs keys:", [...M.trackBlobs.keys()]);
|
||
console.log("M.bulkDownloadStarted:", M.bulkDownloadStarted);
|
||
console.log("=== Playlist Tracks ===");
|
||
M.playlist.forEach((t, i) => {
|
||
const id = t.id || t.filename;
|
||
const segmentCache = M.trackCaches.get(id);
|
||
console.log(`[${i}] ${t.title?.slice(0, 25)}`, {
|
||
id: id,
|
||
segmentCache: segmentCache ? [...segmentCache] : null,
|
||
inCachedTracks: M.cachedTracks.has(id),
|
||
hasBlobUrl: M.trackBlobs.has(id),
|
||
bulkStarted: M.bulkDownloadStarted.get(id)
|
||
});
|
||
});
|
||
};
|
||
|
||
// Debug: check specific track by index
|
||
M.debugTrack = function(index) {
|
||
const track = M.playlist[index];
|
||
if (!track) {
|
||
console.log("No track at index", index);
|
||
return;
|
||
}
|
||
const id = track.id || track.filename;
|
||
const segmentCache = M.trackCaches.get(id);
|
||
console.log("[Track Debug]", {
|
||
index,
|
||
title: track.title,
|
||
id,
|
||
segmentCache: segmentCache ? [...segmentCache] : null,
|
||
inCachedTracks: M.cachedTracks.has(id),
|
||
hasBlobUrl: M.trackBlobs.has(id),
|
||
bulkStarted: M.bulkDownloadStarted.get(id),
|
||
currentTrackId: M.currentTrackId
|
||
});
|
||
};
|
||
|
||
// Clear all caches and start fresh
|
||
M.clearAllCaches = async function() {
|
||
console.log("[Cache] Clearing all caches...");
|
||
await TrackStorage.clear();
|
||
M.cachedTracks.clear();
|
||
M.trackCaches.clear();
|
||
M.trackBlobs.clear();
|
||
M.bulkDownloadStarted.clear();
|
||
M.renderPlaylist();
|
||
M.renderLibrary();
|
||
console.log("[Cache] All caches cleared. Refresh the page.");
|
||
};
|
||
|
||
// Render the current playlist
|
||
M.renderPlaylist = function() {
|
||
const container = M.$("#playlist");
|
||
container.innerHTML = "";
|
||
if (M.playlist.length === 0) {
|
||
container.innerHTML = '<div class="empty">Playlist empty</div>';
|
||
return;
|
||
}
|
||
|
||
// Debug: log first few track cache statuses
|
||
if (M.playlist.length > 0 && M.cachedTracks.size > 0) {
|
||
const sample = M.playlist.slice(0, 3).map(t => {
|
||
const id = t.id || t.filename;
|
||
return { title: t.title?.slice(0, 20), id: id?.slice(0, 12), cached: M.cachedTracks.has(id) };
|
||
});
|
||
console.log("[Playlist Render] Sample tracks:", sample, "| cachedTracks sample:", [...M.cachedTracks].slice(0, 3).map(s => s.slice(0, 12)));
|
||
}
|
||
|
||
M.playlist.forEach((track, i) => {
|
||
const div = document.createElement("div");
|
||
const trackId = track.id || track.filename;
|
||
const isCached = M.cachedTracks.has(trackId);
|
||
div.className = "track" + (i === M.currentIndex ? " active" : "") + (isCached ? " cached" : " not-cached");
|
||
const title = track.title?.trim() || (track.id || track.filename || "Unknown").replace(/\.[^.]+$/, "");
|
||
|
||
// Show remove button only for user playlists (not stream playlists)
|
||
const removeBtn = M.selectedPlaylistId ? `<span class="btn-remove" title="Remove">×</span>` : "";
|
||
div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions">${removeBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`;
|
||
|
||
div.querySelector(".track-title").onclick = async () => {
|
||
if (M.synced && M.currentChannelId) {
|
||
const res = await fetch("/api/channels/" + M.currentChannelId + "/jump", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ index: i })
|
||
});
|
||
if (res.status === 403) M.flashPermissionDenied();
|
||
if (res.status === 400) console.warn("Jump failed: 400 - index:", i, "playlist length:", M.playlist.length);
|
||
} else {
|
||
M.currentIndex = i;
|
||
M.currentTrackId = trackId;
|
||
M.serverTrackDuration = track.duration;
|
||
M.$("#track-title").textContent = title;
|
||
M.loadingSegments.clear();
|
||
const cachedUrl = await M.loadTrackBlob(trackId);
|
||
M.audio.src = cachedUrl || M.getTrackUrl(trackId);
|
||
M.audio.currentTime = 0;
|
||
M.localTimestamp = 0;
|
||
M.audio.play();
|
||
M.renderPlaylist();
|
||
}
|
||
};
|
||
|
||
const removeEl = div.querySelector(".btn-remove");
|
||
if (removeEl) {
|
||
removeEl.onclick = (e) => {
|
||
e.stopPropagation();
|
||
M.removeTrackFromCurrentPlaylist(i);
|
||
};
|
||
}
|
||
|
||
container.appendChild(div);
|
||
});
|
||
};
|
||
|
||
// Render the library
|
||
M.renderLibrary = function() {
|
||
const container = M.$("#library");
|
||
container.innerHTML = "";
|
||
if (M.library.length === 0) {
|
||
container.innerHTML = '<div class="empty">No tracks discovered</div>';
|
||
return;
|
||
}
|
||
const canAdd = M.selectedPlaylistId && M.selectedPlaylistId !== "all";
|
||
M.library.forEach((track) => {
|
||
const div = document.createElement("div");
|
||
const isCached = M.cachedTracks.has(track.id);
|
||
div.className = "track" + (isCached ? " cached" : " not-cached");
|
||
const title = track.title?.trim() || track.filename.replace(/\.[^.]+$/, "");
|
||
const addBtn = canAdd ? `<span class="btn-add" title="Add to playlist">+</span>` : "";
|
||
div.innerHTML = `<span class="cache-indicator"></span><span class="track-title">${title}</span><span class="track-actions">${addBtn}<span class="duration">${M.fmt(track.duration)}</span></span>`;
|
||
|
||
div.querySelector(".track-title").onclick = async () => {
|
||
// Play directly from library (uses track ID)
|
||
if (!M.synced) {
|
||
M.currentTrackId = track.id;
|
||
M.serverTrackDuration = track.duration;
|
||
M.$("#track-title").textContent = title;
|
||
M.loadingSegments.clear();
|
||
const cachedUrl = await M.loadTrackBlob(track.id);
|
||
M.audio.src = cachedUrl || M.getTrackUrl(track.id);
|
||
M.audio.currentTime = 0;
|
||
M.localTimestamp = 0;
|
||
M.audio.play();
|
||
}
|
||
};
|
||
|
||
const addBtnEl = div.querySelector(".btn-add");
|
||
if (addBtnEl) {
|
||
addBtnEl.onclick = (e) => {
|
||
e.stopPropagation();
|
||
M.addTrackToCurrentPlaylist(track.id);
|
||
};
|
||
}
|
||
|
||
container.appendChild(div);
|
||
});
|
||
};
|
||
|
||
// Load library from server
|
||
M.loadLibrary = async function() {
|
||
try {
|
||
const res = await fetch("/api/library");
|
||
M.library = await res.json();
|
||
M.renderLibrary();
|
||
} catch (e) {
|
||
console.warn("Failed to load library");
|
||
}
|
||
};
|
||
|
||
// Load user playlists from server
|
||
M.loadPlaylists = async function() {
|
||
try {
|
||
const res = await fetch("/api/playlists");
|
||
M.userPlaylists = await res.json();
|
||
M.renderPlaylistSelector();
|
||
} catch (e) {
|
||
console.warn("Failed to load playlists");
|
||
}
|
||
};
|
||
|
||
// Render playlist selector sidebar
|
||
M.renderPlaylistSelector = function() {
|
||
const list = M.$("#playlists-list");
|
||
if (!list) return;
|
||
list.innerHTML = "";
|
||
// Add "All Tracks" as default option
|
||
const allItem = document.createElement("div");
|
||
allItem.className = "playlist-item" + (M.selectedPlaylistId === "all" ? " active" : "");
|
||
allItem.textContent = "All Tracks";
|
||
allItem.onclick = () => M.loadSelectedPlaylist("all");
|
||
list.appendChild(allItem);
|
||
// Add user playlists
|
||
for (const pl of M.userPlaylists) {
|
||
const item = document.createElement("div");
|
||
item.className = "playlist-item" + (pl.id === M.selectedPlaylistId ? " active" : "");
|
||
item.textContent = pl.name;
|
||
item.onclick = () => M.loadSelectedPlaylist(pl.id);
|
||
list.appendChild(item);
|
||
}
|
||
// Update playlist panel title
|
||
const titleEl = M.$("#playlist-title");
|
||
if (M.selectedPlaylistId === "all") {
|
||
titleEl.textContent = "Playlist - All Tracks";
|
||
} else if (M.selectedPlaylistId) {
|
||
const pl = M.userPlaylists.find(p => p.id === M.selectedPlaylistId);
|
||
titleEl.textContent = pl ? "Playlist - " + pl.name : "Playlist";
|
||
} else {
|
||
titleEl.textContent = "Playlist";
|
||
}
|
||
};
|
||
|
||
// Load and display a specific playlist
|
||
M.loadSelectedPlaylist = async function(playlistId) {
|
||
if (!playlistId) {
|
||
M.playlist = [];
|
||
M.selectedPlaylistId = null;
|
||
M.renderPlaylist();
|
||
M.renderPlaylistSelector();
|
||
M.renderLibrary();
|
||
return;
|
||
}
|
||
if (playlistId === "all") {
|
||
// Use library as playlist
|
||
M.playlist = [...M.library];
|
||
M.selectedPlaylistId = "all";
|
||
M.currentIndex = 0;
|
||
M.renderPlaylist();
|
||
M.renderPlaylistSelector();
|
||
M.renderLibrary();
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch("/api/playlists/" + playlistId);
|
||
if (!res.ok) throw new Error("Failed to load playlist");
|
||
const data = await res.json();
|
||
M.playlist = data.tracks || [];
|
||
M.selectedPlaylistId = playlistId;
|
||
M.currentIndex = 0;
|
||
M.renderPlaylist();
|
||
M.renderPlaylistSelector();
|
||
M.renderLibrary();
|
||
} catch (e) {
|
||
console.warn("Failed to load playlist:", e);
|
||
}
|
||
};
|
||
|
||
// Create a new playlist
|
||
M.createNewPlaylist = async function() {
|
||
const header = M.$("#playlists-panel .panel-header");
|
||
const btn = M.$("#btn-new-playlist");
|
||
|
||
// Already in edit mode?
|
||
if (header.querySelector(".new-playlist-input")) return;
|
||
|
||
// Hide button, show input
|
||
btn.style.display = "none";
|
||
|
||
const input = document.createElement("input");
|
||
input.type = "text";
|
||
input.className = "new-playlist-input";
|
||
input.placeholder = "Playlist name...";
|
||
|
||
const submit = document.createElement("button");
|
||
submit.className = "btn-submit-playlist";
|
||
submit.textContent = "›";
|
||
|
||
header.appendChild(input);
|
||
header.appendChild(submit);
|
||
input.focus();
|
||
|
||
const cleanup = () => {
|
||
input.remove();
|
||
submit.remove();
|
||
btn.style.display = "";
|
||
};
|
||
|
||
const doCreate = async () => {
|
||
const name = input.value.trim();
|
||
if (!name) {
|
||
cleanup();
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch("/api/playlists", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ name, visibility: "private" })
|
||
});
|
||
if (!res.ok) throw new Error("Failed to create playlist");
|
||
const pl = await res.json();
|
||
await M.loadPlaylists();
|
||
M.selectedPlaylistId = pl.id;
|
||
M.renderPlaylistSelector();
|
||
await M.loadSelectedPlaylist(pl.id);
|
||
} catch (e) {
|
||
alert("Failed to create playlist");
|
||
}
|
||
cleanup();
|
||
};
|
||
|
||
submit.onclick = doCreate;
|
||
input.onkeydown = (e) => {
|
||
if (e.key === "Enter") doCreate();
|
||
if (e.key === "Escape") cleanup();
|
||
};
|
||
input.onblur = (e) => {
|
||
// Delay to allow click on submit button
|
||
setTimeout(() => {
|
||
if (document.activeElement !== submit) cleanup();
|
||
}, 100);
|
||
};
|
||
};
|
||
|
||
// Add a track to current playlist
|
||
M.addTrackToCurrentPlaylist = async function(trackId) {
|
||
if (!M.selectedPlaylistId || M.selectedPlaylistId === "all") {
|
||
alert("Select or create a playlist first");
|
||
return;
|
||
}
|
||
try {
|
||
const res = await fetch("/api/playlists/" + M.selectedPlaylistId + "/tracks", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ trackIds: [trackId] })
|
||
});
|
||
if (!res.ok) throw new Error("Failed to add track");
|
||
await M.loadSelectedPlaylist(M.selectedPlaylistId);
|
||
} catch (e) {
|
||
console.warn("Failed to add track:", e);
|
||
}
|
||
};
|
||
|
||
// Remove a track from current playlist
|
||
M.removeTrackFromCurrentPlaylist = async function(position) {
|
||
if (!M.selectedPlaylistId || M.selectedPlaylistId === "all") return;
|
||
try {
|
||
const res = await fetch("/api/playlists/" + M.selectedPlaylistId + "/tracks/" + position, {
|
||
method: "DELETE"
|
||
});
|
||
if (!res.ok) throw new Error("Failed to remove track");
|
||
await M.loadSelectedPlaylist(M.selectedPlaylistId);
|
||
} catch (e) {
|
||
console.warn("Failed to remove track:", e);
|
||
}
|
||
};
|
||
})();
|