blastoise-archive/public/playlist.js

427 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 synced to a channel, broadcast playlist change to server
if (M.synced && M.currentChannelId) {
try {
const res = await fetch("/api/channels/" + M.currentChannelId + "/playlist", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ playlistId: playlistId === "all" ? "all" : parseInt(playlistId) })
});
if (res.status === 403) {
M.flashPermissionDenied();
return;
}
if (!res.ok) throw new Error("Failed to set playlist");
M.selectedPlaylistId = playlistId;
M.renderPlaylistSelector();
// Server will broadcast new playlist via WebSocket
return;
} catch (e) {
console.warn("Failed to set channel playlist:", e);
}
}
// Local mode - load playlist directly
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);
}
};
})();