mirror of
https://github.com/19JVJeffery/tiddl-gui.git
synced 2026-10-22 10:52:16 -05:00
- Safely normalize verificationUriComplete: only prepend https:// when the field does not already include a protocol, preventing a broken https://https://... URL if TIDAL includes the scheme in the response. Falls back to verificationUri, and throws clearly if neither is present. - Remove the HTTP status === 400 guard on authorization_pending detection to match the tiddl Python reference implementation, which only checks the error field regardless of the HTTP status code used by TIDAL. Agent-Logs-Url: https://github.com/19JVJeffery/tiddl-gui/sessions/1f8e8ba8-d963-4f70-a090-ea9468a19df3 Co-authored-by: 19JVJeffery <94115228+19JVJeffery@users.noreply.github.com>
2360 lines
96 KiB
JavaScript
2360 lines
96 KiB
JavaScript
/**
|
|
* tiddl-web — main application controller
|
|
*/
|
|
|
|
const LOGIN_SUCCESS_REDIRECT_DELAY_MS = 800;
|
|
|
|
import {
|
|
getDeviceAuth, pollDeviceAuth, logout,
|
|
isLoggedIn, loadAuth, saveAuth,
|
|
} from "./auth.js";
|
|
import {
|
|
search,
|
|
getTrack,
|
|
getArtist, getArtistAlbums, getArtistSingles,
|
|
getAlbum,
|
|
getPlaylist,
|
|
getVideo,
|
|
getUserFavoriteTracks, getUserFavoriteAlbums,
|
|
getUserFavoritePlaylists, getUserPlaylists,
|
|
getAllUserFavoriteTracks, getAllUserFavoriteAlbums,
|
|
getAllUserFavoritePlaylists, getAllUserPlaylists,
|
|
getAllAlbumItems, getAllPlaylistItems,
|
|
} from "./api.js";
|
|
import {
|
|
parseTidalInput, downloadTrack, downloadAlbum,
|
|
downloadPlaylist, downloadMix, downloadArtistAlbums,
|
|
} from "./download.js";
|
|
import {
|
|
getTheme, getAccentColor, getTrackQuality, setTrackQuality, getAdvancedMode, getAllQualitiesMode,
|
|
getPreferredFormat,
|
|
QUALITY_LABELS, QUALITY_DESCRIPTIONS, QUALITY_STANDARD,
|
|
loadSearchHistory, saveToSearchHistory, clearSearchHistory,
|
|
} from "./config.js";
|
|
import {
|
|
applyTheme, applyAccentColor, cycleTheme,
|
|
initThemeUI, initAccentColorUI,
|
|
initTemplateBuilders, loadSettingsForm, saveSettingsForm,
|
|
initBrowserChrome, initUiEffectsUI,
|
|
initExperimentalSettingUI,
|
|
} from "./settings.js";
|
|
|
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
|
|
// QUALITY_LABELS, QUALITY_STANDARD are imported from config.js
|
|
|
|
/** Quality tiers ordered from highest to lowest, used to find the best quality in a collection. */
|
|
const QUALITY_ORDER = [...QUALITY_STANDARD];
|
|
|
|
/** Returns HTML for a small quality pill, or empty string when quality is unknown. */
|
|
function qualityPill(quality) {
|
|
if (!quality || !QUALITY_LABELS[quality]) return "";
|
|
return `<span class="quality-pill-sm" data-quality="${escHtml(quality)}">${escHtml(QUALITY_LABELS[quality])}</span>`;
|
|
}
|
|
|
|
/**
|
|
* Returns the best available quality key for a track or album object.
|
|
* Tidal's `audioQuality` field can report `LOSSLESS` even for Hi-Res content;
|
|
* the authoritative indicator is `mediaMetadata.tags` which includes
|
|
* `"HIRES_LOSSLESS"` when the Max tier is available.
|
|
*/
|
|
function effectiveQuality(item) {
|
|
if (!item) return null;
|
|
const tags = item.mediaMetadata?.tags;
|
|
if (Array.isArray(tags) && tags.includes("HIRES_LOSSLESS")) return "HI_RES_LOSSLESS";
|
|
return item.audioQuality || null;
|
|
}
|
|
|
|
/** Returns the display title for a track, appending the version in parentheses when present. */
|
|
function formatTrackTitle(track) {
|
|
return track.version ? `${track.title} (${track.version})` : track.title;
|
|
}
|
|
|
|
/** Formats a duration in total seconds to M:SS or H:MM:SS. */
|
|
function formatDuration(totalSeconds) {
|
|
const s = Math.round(totalSeconds || 0);
|
|
const h = Math.floor(s / 3600);
|
|
const m = Math.floor((s % 3600) / 60);
|
|
const sec = s % 60;
|
|
if (h > 0) {
|
|
return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
}
|
|
return `${m}:${String(sec).padStart(2, "0")}`;
|
|
}
|
|
|
|
/**
|
|
* File extensions that are binary formats where reading as UTF-8 text is
|
|
* unreliable and Tidal URL extraction will likely fail.
|
|
* - .doc / .docx / .odt: ZIP-compressed XML — content is not readable as text
|
|
* - .pdf: binary object-stream format — text may be partially readable but not guaranteed
|
|
*/
|
|
const BINARY_OFFICE_EXTS = new Set(["doc", "docx", "odt", "pdf"]);
|
|
|
|
// ─── Utility ─────────────────────────────────────────────────────────────────
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
function show(el) { if (el) el.classList.remove("hidden"); }
|
|
function hide(el) { if (el) el.classList.add("hidden"); }
|
|
function setHtml(el, html) { if (el) el.innerHTML = html; }
|
|
|
|
function escHtml(str) {
|
|
return String(str)
|
|
.replace(/&/g, "&").replace(/</g, "<")
|
|
.replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
function coverUrl(hash, size = 320) {
|
|
return hash
|
|
? `https://resources.tidal.com/images/${hash.replace(/-/g, "/")}/${size}x${size}.jpg`
|
|
: "";
|
|
}
|
|
|
|
/**
|
|
* Extract candidate Tidal resource tokens from arbitrary file text.
|
|
*
|
|
* Strategy:
|
|
* 1. Strip RTF control sequences so URLs inside .rtf are exposed as plain text.
|
|
* 2. Find all full Tidal HTTPS URLs via regex — handles JSON values, HTML href
|
|
* attributes, YAML strings, XML content, Markdown links, etc.
|
|
* 3. Find short resource IDs (e.g. "track/103805726") anywhere in the text.
|
|
* 4. If neither regex found anything (truly plain-text files that may use bare
|
|
* numbers or non-URL forms), fall back to per-line parsing — but only accept
|
|
* lines that already look like a URL, a short ID, or a bare numeric track ID
|
|
* to avoid flooding the activity log with warnings for every non-URL line.
|
|
*
|
|
* @param {string} rawText UTF-8 decoded file content
|
|
* @param {string} ext Lowercase file extension without the dot
|
|
* @returns {string[]} Deduplicated list of candidate token strings
|
|
*/
|
|
function extractTidalTokens(rawText, ext) {
|
|
let text = rawText;
|
|
|
|
// RTF: strip backslash control words, hex escapes, and braces to expose plain text
|
|
if (ext === "rtf") {
|
|
text = text
|
|
.replace(/\\[a-z*]+-?\d*\s?/gi, " ") // control words like \rtf1, \fonttbl (allow any whitespace separator)
|
|
.replace(/\\'[0-9a-f]{2}/gi, "") // hex character escapes like \'e9
|
|
.replace(/[{}\\]/g, " "); // braces and remaining backslashes
|
|
}
|
|
|
|
const tokens = new Set();
|
|
|
|
// ── 1. Full Tidal HTTPS URLs ────────────────────────────────────────────────
|
|
// Matches https://tidal.com/..., https://listen.tidal.com/..., etc.
|
|
// Exclude braces so JSON object boundaries don't get consumed into the URL.
|
|
const tidalUrlRe = /https?:\/\/(?:[a-z0-9-]+\.)*tidal\.com\/[^\s"'<>&,;)\]\\{}]+/gi;
|
|
for (const m of text.matchAll(tidalUrlRe)) {
|
|
const url = m[0].replace(/[.,;)>\]'"\\}]+$/, "").trim();
|
|
if (url) tokens.add(url);
|
|
}
|
|
|
|
// ── 2. Short resource IDs ───────────────────────────────────────────────────
|
|
// Matches track/123, album/abc-uuid, playlist/uuid, mix/id, video/id, artist/id.
|
|
// Upper bound of 80 chars covers the longest expected Tidal IDs (playlist UUIDs
|
|
// are 36 chars; using 80 gives headroom without matching unrelated path segments).
|
|
const shortIdRe = /\b(track|album|playlist|mix|video|artist)\/([a-zA-Z0-9_-]{2,80})\b/gi;
|
|
for (const m of text.matchAll(shortIdRe)) {
|
|
tokens.add(`${m[1].toLowerCase()}/${m[2]}`);
|
|
}
|
|
|
|
// ── 3. Line-by-line fallback (plain .txt / .csv / etc.) ─────────────────────
|
|
// Only runs when neither regex found anything, and only accepts lines that
|
|
// look like a Tidal URL, a short resource ID, or a bare numeric track ID.
|
|
// Lines longer than 500 characters are skipped — legitimate Tidal URLs and
|
|
// resource IDs are always shorter; very long lines indicate non-URL content.
|
|
if (tokens.size === 0) {
|
|
for (const raw of text.split(/[\n\r,\t;|]+/)) {
|
|
const line = raw.trim().replace(/^["'`([\]{}<>]+|["'`)\]}>.,;\\]+$/g, "").trim();
|
|
if (!line || line.length > 500) continue;
|
|
if (
|
|
line.startsWith("http") ||
|
|
/^(track|album|playlist|mix|video|artist)\//i.test(line) ||
|
|
/^\d{4,12}$/.test(line) // bare numeric track ID
|
|
) {
|
|
tokens.add(line);
|
|
}
|
|
}
|
|
}
|
|
|
|
return [...tokens];
|
|
}
|
|
|
|
// ─── Log ─────────────────────────────────────────────────────────────────────
|
|
|
|
function appendLog(msg, type = "info") {
|
|
const log = $("download-log");
|
|
if (!log) return;
|
|
const line = document.createElement("div");
|
|
line.className = `log-line log-${type}`;
|
|
line.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
|
|
log.appendChild(line);
|
|
log.scrollTop = log.scrollHeight;
|
|
}
|
|
function clearLog() {
|
|
const log = $("download-log");
|
|
if (log) log.innerHTML = "";
|
|
}
|
|
|
|
// ─── Download status panel tabs ───────────────────────────────────────────────
|
|
|
|
function switchDlTab(tab) {
|
|
const progressView = $("dl-progress-view");
|
|
const logView = $("dl-log-view");
|
|
const progressBtn = $("dl-tab-progress-btn");
|
|
const logBtn = $("dl-tab-log-btn");
|
|
const clearLogBtn = $("btn-clear-log");
|
|
const exportLogBtn = $("btn-export-log");
|
|
if (tab === "log") {
|
|
progressView?.classList.add("hidden");
|
|
logView?.classList.remove("hidden");
|
|
progressBtn?.classList.remove("active");
|
|
logBtn?.classList.add("active");
|
|
clearLogBtn?.classList.remove("hidden");
|
|
exportLogBtn?.classList.remove("hidden");
|
|
logBtn?.setAttribute("aria-selected", "true");
|
|
progressBtn?.setAttribute("aria-selected", "false");
|
|
} else {
|
|
logView?.classList.add("hidden");
|
|
progressView?.classList.remove("hidden");
|
|
logBtn?.classList.remove("active");
|
|
progressBtn?.classList.add("active");
|
|
clearLogBtn?.classList.add("hidden");
|
|
exportLogBtn?.classList.add("hidden");
|
|
progressBtn?.setAttribute("aria-selected", "true");
|
|
logBtn?.setAttribute("aria-selected", "false");
|
|
}
|
|
}
|
|
|
|
function exportLog() {
|
|
const log = $("download-log");
|
|
if (!log) return;
|
|
const lines = [...log.querySelectorAll(".log-line")].map((el) => el.textContent).join("\n");
|
|
if (!lines) return;
|
|
const blob = new Blob([lines], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `tiddl-log-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}.txt`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
// ─── Per-item progress list ───────────────────────────────────────────────────
|
|
|
|
function buildProgressList(items) {
|
|
const list = $("dl-item-progress-list");
|
|
if (!list) return;
|
|
list.innerHTML = items.map((item, idx) => {
|
|
const thumbHtml = item.cover
|
|
? `<img src="${escHtml(item.cover)}" alt="" class="dl-item-thumb${item.coverRound ? " round" : ""}" loading="lazy" />`
|
|
: `<div class="dl-item-thumb${item.coverRound ? " round" : ""}"></div>`;
|
|
const isMulti = ["album", "playlist", "mix", "artist"].includes(item.type);
|
|
const expandBtn = isMulti
|
|
? `<button class="dl-item-expand-btn" data-expand-idx="${idx}" aria-label="Toggle download progress details" aria-expanded="false" title="Expand to see individual track progress">
|
|
<svg width="10" height="10" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"/></svg>
|
|
</button>`
|
|
: "";
|
|
return `<div class="dl-item-row dl-item-row--pending" id="dl-item-row-${idx}">
|
|
${thumbHtml}
|
|
<div class="dl-item-details">
|
|
<div class="dl-item-title">${escHtml(item.title)}</div>
|
|
<div class="dl-item-msg" id="dl-item-msg-${idx}">Waiting\u2026</div>
|
|
<div class="dl-item-bar-track"><div class="dl-item-bar-fill" id="dl-item-bar-${idx}" style="width:0%"></div></div>
|
|
</div>
|
|
${expandBtn}
|
|
<span class="dl-item-icon" id="dl-item-icon-${idx}"></span>
|
|
</div>
|
|
<div class="dl-item-sub-list hidden" id="dl-item-sub-${idx}"></div>`;
|
|
}).join("");
|
|
|
|
// Use event delegation on the list container to avoid per-button listeners
|
|
list.addEventListener("click", (e) => {
|
|
const btn = e.target.closest(".dl-item-expand-btn");
|
|
if (!btn) return;
|
|
const idx = btn.dataset.expandIdx;
|
|
const subList = $(`dl-item-sub-${idx}`);
|
|
if (!subList) return;
|
|
const expanded = btn.getAttribute("aria-expanded") === "true";
|
|
btn.setAttribute("aria-expanded", String(!expanded));
|
|
btn.classList.toggle("expanded", !expanded);
|
|
subList.classList.toggle("hidden", expanded);
|
|
});
|
|
}
|
|
|
|
// ─── Per-item sub-progress rows ───────────────────────────────────────────────
|
|
|
|
function upsertSubItemRow(rowIdx, subIdx, title, done, total, status) {
|
|
const subList = $(`dl-item-sub-${rowIdx}`);
|
|
if (!subList) return;
|
|
|
|
let subRow = $(`dl-sub-item-${rowIdx}-${subIdx}`);
|
|
if (!subRow) {
|
|
subRow = document.createElement("div");
|
|
subRow.id = `dl-sub-item-${rowIdx}-${subIdx}`;
|
|
subRow.className = "dl-sub-item dl-sub-item--active";
|
|
subRow.innerHTML = `
|
|
<div class="dl-sub-item-details">
|
|
<div class="dl-sub-item-title" id="dl-sub-item-title-${rowIdx}-${subIdx}">${escHtml(title)}</div>
|
|
<div class="dl-sub-item-bar-track">
|
|
<div class="dl-sub-item-bar-fill" id="dl-sub-item-bar-${rowIdx}-${subIdx}" style="width:0%"></div>
|
|
</div>
|
|
</div>
|
|
<span class="dl-sub-item-icon" id="dl-sub-item-icon-${rowIdx}-${subIdx}"></span>
|
|
`;
|
|
subList.appendChild(subRow);
|
|
}
|
|
|
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
const barEl = $(`dl-sub-item-bar-${rowIdx}-${subIdx}`);
|
|
const iconEl = $(`dl-sub-item-icon-${rowIdx}-${subIdx}`);
|
|
const titleEl = $(`dl-sub-item-title-${rowIdx}-${subIdx}`);
|
|
|
|
if (barEl) barEl.style.width = `${pct}%`;
|
|
if (titleEl) titleEl.textContent = title;
|
|
subRow.className = `dl-sub-item dl-sub-item--${status}`;
|
|
if (iconEl) {
|
|
iconEl.textContent = status === "done" ? "\u2713" : status === "failed" ? "\u2717" : "";
|
|
}
|
|
}
|
|
|
|
// ─── Nav ─────────────────────────────────────────────────────────────────────
|
|
|
|
let _detailOriginTab = "search";
|
|
let _detailNavStack = []; // Stack of previously viewed detail states
|
|
let _currentDetail = null; // Currently displayed detail state
|
|
|
|
function activateTab(tabId) {
|
|
_currentDetail = null;
|
|
_detailNavStack = [];
|
|
document.querySelectorAll(".tab-btn").forEach((btn) =>
|
|
btn.classList.toggle("active", btn.dataset.tab === tabId)
|
|
);
|
|
document.querySelectorAll(".tab-panel").forEach((panel) =>
|
|
panel.classList.toggle("hidden", panel.id !== `panel-${tabId}`)
|
|
);
|
|
}
|
|
|
|
function openDetailPanel(fromTab) {
|
|
// Only update the origin tab on fresh navigations; preserve it when drilling deeper.
|
|
if (_detailNavStack.length === 0) {
|
|
_detailOriginTab = fromTab || "search";
|
|
}
|
|
// Show detail panel without changing sidebar active state
|
|
document.querySelectorAll(".tab-panel").forEach((p) =>
|
|
p.classList.toggle("hidden", p.id !== "panel-detail")
|
|
);
|
|
// Keep origin tab highlighted
|
|
document.querySelectorAll(".tab-btn").forEach((btn) =>
|
|
btn.classList.toggle("active", btn.dataset.tab === _detailOriginTab)
|
|
);
|
|
_updateDetailBackLabel();
|
|
}
|
|
|
|
function _updateDetailBackLabel() {
|
|
const lbl = $("btn-detail-back-label");
|
|
if (!lbl) return;
|
|
if (_detailNavStack.length > 0) {
|
|
// Label shows the title of the detail we'd return to.
|
|
lbl.textContent = _detailNavStack[_detailNavStack.length - 1].title || "Back";
|
|
} else {
|
|
const names = { search: "Search results", library: "Library" };
|
|
lbl.textContent = names[_detailOriginTab] || "Back";
|
|
}
|
|
}
|
|
|
|
function backFromDetail() {
|
|
if (_detailNavStack.length > 0) {
|
|
const prev = _detailNavStack.pop();
|
|
_currentDetail = null;
|
|
navigateToDetail(prev.type, prev.id, prev.title, prev.sub, prev.cover, _detailOriginTab, prev.hintQuality, true);
|
|
} else {
|
|
activateTab(_detailOriginTab);
|
|
}
|
|
}
|
|
|
|
// ─── Auth UI ─────────────────────────────────────────────────────────────────
|
|
|
|
function updateAuthBadge() {
|
|
const badge = $("auth-badge");
|
|
const logoutBtn = $("btn-logout");
|
|
const loggedInView = $("auth-logged-in");
|
|
const loginView = $("auth-login-view");
|
|
if (!badge) return;
|
|
if (isLoggedIn()) {
|
|
const auth = loadAuth();
|
|
const username = auth?.username || "Logged in";
|
|
badge.textContent = username;
|
|
badge.className = "auth-badge auth-badge--in";
|
|
show(logoutBtn);
|
|
// Show logged-in view on Account tab
|
|
if (loggedInView) {
|
|
show(loggedInView);
|
|
const nameEl = $("auth-account-username");
|
|
if (nameEl) nameEl.textContent = username;
|
|
}
|
|
if (loginView) hide(loginView);
|
|
} else {
|
|
badge.textContent = "Not logged in";
|
|
badge.className = "auth-badge auth-badge--out";
|
|
hide(logoutBtn);
|
|
if (loggedInView) hide(loggedInView);
|
|
if (loginView) show(loginView);
|
|
}
|
|
}
|
|
|
|
let pollTimer = null;
|
|
|
|
async function startLogin() {
|
|
const loginBtn = $("btn-login");
|
|
const loginStatus = $("login-status");
|
|
loginBtn.disabled = true;
|
|
setHtml(loginStatus, '<span class="spinner"></span> Requesting device code\u2026');
|
|
|
|
try {
|
|
const deviceAuth = await getDeviceAuth();
|
|
// Normalize the verification URI: TIDAL may or may not include the scheme.
|
|
// Prepend https:// only when the field does not already start with a protocol.
|
|
const rawUri = deviceAuth.verificationUriComplete || deviceAuth.verificationUri;
|
|
if (!rawUri) throw new Error("No verification URI returned by TIDAL");
|
|
const uri = /^https?:\/\//i.test(rawUri) ? rawUri : `https://${rawUri}`;
|
|
setHtml(loginStatus,
|
|
`<p>Open the link below and approve the request, then wait here.</p>
|
|
<a href="${escHtml(uri)}" target="_blank" rel="noopener" class="login-link">${escHtml(uri)}</a>`
|
|
);
|
|
const endAt = Date.now() + deviceAuth.expiresIn * 1000;
|
|
const poll = async () => {
|
|
if (Date.now() >= endAt) {
|
|
setHtml(loginStatus, '<span class="error">Authentication expired. Try again.</span>');
|
|
loginBtn.disabled = false;
|
|
return;
|
|
}
|
|
try {
|
|
const authResponse = await pollDeviceAuth(deviceAuth.deviceCode);
|
|
saveAuth({
|
|
token: authResponse.access_token,
|
|
refresh_token: authResponse.refresh_token,
|
|
expires_at: Math.floor(Date.now() / 1000) + authResponse.expires_in,
|
|
user_id: String(authResponse.user_id),
|
|
country_code: authResponse.user?.countryCode || "US",
|
|
username: authResponse.user?.username || authResponse.user?.email || "User",
|
|
});
|
|
setHtml(loginStatus, '<span class="success">\u2713 Logged in successfully!</span>');
|
|
loginBtn.disabled = false;
|
|
updateAuthBadge();
|
|
sessionStorage.setItem("tiddl_session_welcome", "1");
|
|
setTimeout(() => {
|
|
activateTab("library");
|
|
loadLibraryIfNeeded();
|
|
}, LOGIN_SUCCESS_REDIRECT_DELAY_MS);
|
|
} catch (err) {
|
|
if (err?.error === "authorization_pending") {
|
|
const secsLeft = Math.max(0, Math.round((endAt - Date.now()) / 1000));
|
|
const mins = Math.floor(secsLeft / 60);
|
|
const secs = secsLeft % 60;
|
|
setHtml(loginStatus,
|
|
`<p>Waiting for approval\u2026 <span class="countdown">${mins}:${String(secs).padStart(2, "0")}</span></p>
|
|
<a href="${escHtml(uri)}" target="_blank" rel="noopener" class="login-link">${escHtml(uri)}</a>`
|
|
);
|
|
pollTimer = setTimeout(poll, deviceAuth.interval * 1000);
|
|
} else if (err?.error === "expired_token") {
|
|
setHtml(loginStatus, '<span class="error">Device code expired. Try again.</span>');
|
|
loginBtn.disabled = false;
|
|
} else {
|
|
const msg = err?.error_description || err?.message || JSON.stringify(err);
|
|
setHtml(loginStatus, `<span class="error">Error: ${escHtml(msg)}</span>`);
|
|
loginBtn.disabled = false;
|
|
}
|
|
}
|
|
};
|
|
pollTimer = setTimeout(poll, deviceAuth.interval * 1000);
|
|
} catch (err) {
|
|
const msg = err?.message || JSON.stringify(err);
|
|
const isNetworkError = msg.toLowerCase().includes("failed to fetch") || msg.toLowerCase().includes("networkerror");
|
|
const hint = isNetworkError
|
|
? `<br><small style="opacity:0.8">A browser extension or firewall may be blocking the CORS proxy. Try disabling extensions, or change the proxy in Settings \u2192 Advanced.</small>`
|
|
: "";
|
|
setHtml(loginStatus, `<span class="error">Failed to start login: ${escHtml(msg)}${hint}</span>`);
|
|
loginBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function handleLogout() {
|
|
if (pollTimer) clearTimeout(pollTimer);
|
|
await logout();
|
|
updateAuthBadge();
|
|
setHtml($("login-status"), "");
|
|
activateTab("auth");
|
|
appendLog("Logged out.", "info");
|
|
}
|
|
|
|
// ─── Quality ─────────────────────────────────────────────────────────────────
|
|
|
|
/** Returns the available quality options. */
|
|
function visibleQualityOptions() {
|
|
return QUALITY_STANDARD;
|
|
}
|
|
|
|
/**
|
|
* Build <option> elements for a quality <select>.
|
|
* @param {string} selectedValue - The currently selected quality key.
|
|
* @param {boolean} [detailed=false] - When true, use full descriptions (e.g. "High — 320 kbps · M4A").
|
|
* @param {string|null} [maxQuality=null] - When set, options above this quality tier are disabled.
|
|
*/
|
|
function qualityOptions(selectedValue, detailed = false, maxQuality = null) {
|
|
const options = visibleQualityOptions();
|
|
const maxIdx = maxQuality ? QUALITY_ORDER.indexOf(maxQuality) : -1;
|
|
return options.map((v) => {
|
|
const baseLabel = detailed
|
|
? (QUALITY_DESCRIPTIONS[v] || QUALITY_LABELS[v] || v)
|
|
: (QUALITY_LABELS[v] || v);
|
|
const vIdx = QUALITY_ORDER.indexOf(v);
|
|
// An option is unavailable when it is a higher tier than the item's known max quality
|
|
const unavailable = maxIdx !== -1 && vIdx !== -1 && vIdx < maxIdx;
|
|
const label = unavailable ? `${baseLabel} (n/a)` : baseLabel;
|
|
return `<option value="${v}"${v === selectedValue ? " selected" : ""}${unavailable ? " disabled" : ""}>${escHtml(label)}</option>`;
|
|
}).join("");
|
|
}
|
|
|
|
/** Sets data-quality on a select element to match its current value, enabling CSS pill-style colouring. */
|
|
function updateQualitySelectStyle(select) {
|
|
if (!select) return;
|
|
select.dataset.quality = select.value;
|
|
}
|
|
|
|
/**
|
|
* Tooltip text for the quality picker.
|
|
*/
|
|
function qualityPickerTip() {
|
|
return "Download quality. In normal mode, applies to all queued items. In Advanced mode (Settings), set quality per item.";
|
|
}
|
|
|
|
function getSelectedQuality() {
|
|
return $("quality-select")?.value || "HIGH";
|
|
}
|
|
|
|
/**
|
|
* Rebuild the <option> list of the main quality <select> and update its tooltip.
|
|
*/
|
|
function rebuildQualityPicker() {
|
|
const select = $("quality-select");
|
|
if (!select) return;
|
|
|
|
const current = select.value || getTrackQuality();
|
|
select.innerHTML = qualityOptions(current, true);
|
|
|
|
// Ensure selected value is still valid; fall back to saved or HIGH
|
|
const validOptions = visibleQualityOptions();
|
|
if (!validOptions.includes(select.value)) {
|
|
select.value = validOptions.includes(getTrackQuality()) ? getTrackQuality() : "HIGH";
|
|
}
|
|
|
|
// Sync pill-style colouring
|
|
updateQualitySelectStyle(select);
|
|
|
|
// Update the tooltip title attribute
|
|
select.title = qualityPickerTip();
|
|
|
|
// Update the associated tip element if present
|
|
const tipEl = select.closest(".quality-picker-wrap")?.querySelector("[data-tip]");
|
|
if (tipEl) tipEl.dataset.tip = qualityPickerTip();
|
|
|
|
// Reflect which quality tiers are actually available for the current queue
|
|
updateQualityPickerAvailability();
|
|
}
|
|
|
|
function syncQualityPicker() {
|
|
const select = $("quality-select");
|
|
if (!select) return;
|
|
rebuildQualityPicker();
|
|
select.value = getTrackQuality();
|
|
updateQualitySelectStyle(select);
|
|
}
|
|
|
|
/**
|
|
* Returns the quality that will actually be used for a queue item given the
|
|
* requested quality. When the item has a known `maxQuality` that is lower than
|
|
* the requested quality, the item's max quality is returned instead.
|
|
* @param {{maxQuality?: string|null}} item
|
|
* @param {string} requestedQuality
|
|
* @returns {string}
|
|
*/
|
|
function effectiveDownloadQuality(item, requestedQuality) {
|
|
if (!item.maxQuality) return requestedQuality;
|
|
const reqIdx = QUALITY_ORDER.indexOf(requestedQuality);
|
|
const maxIdx = QUALITY_ORDER.indexOf(item.maxQuality);
|
|
if (reqIdx === -1 || maxIdx === -1) return requestedQuality;
|
|
// QUALITY_ORDER is highest-first; a larger index means a lower quality tier.
|
|
// Cap the requested quality at the item's max by taking the larger (lower-quality) index.
|
|
return QUALITY_ORDER[Math.max(reqIdx, maxIdx)];
|
|
}
|
|
|
|
/**
|
|
* Enable or disable the HI_RES_LOSSLESS option in the global quality picker
|
|
* based on how many queued items with a known max quality actually support it.
|
|
* Items whose max quality is unknown (e.g. albums/playlists pasted by URL) are
|
|
* excluded from the count so they don't incorrectly suppress the option.
|
|
*/
|
|
function updateQualityPickerAvailability() {
|
|
const select = $("quality-select");
|
|
if (!select) return;
|
|
|
|
const knownItems = downloadQueue.filter((q) => q.maxQuality);
|
|
const anySupportsMax = knownItems.some((q) => q.maxQuality === "HI_RES_LOSSLESS");
|
|
const disableMax = knownItems.length > 0 && !anySupportsMax;
|
|
|
|
const maxOpt = select.querySelector('option[value="HI_RES_LOSSLESS"]');
|
|
if (!maxOpt) return;
|
|
|
|
if (disableMax) {
|
|
maxOpt.disabled = true;
|
|
maxOpt.textContent = "Max \u2014 not available";
|
|
if (select.value === "HI_RES_LOSSLESS") {
|
|
// Silently fall back to the next best tier without persisting the change
|
|
// so the preference is restored when Max becomes available again.
|
|
const fallback = QUALITY_ORDER.find((q) => q !== "HI_RES_LOSSLESS") ?? "LOSSLESS";
|
|
select.value = fallback;
|
|
updateQualitySelectStyle(select);
|
|
}
|
|
} else {
|
|
maxOpt.disabled = false;
|
|
maxOpt.textContent = QUALITY_DESCRIPTIONS.HI_RES_LOSSLESS || "Max \u2014 FLAC 24-bit";
|
|
}
|
|
}
|
|
|
|
function initQualityPicker() {
|
|
const select = $("quality-select");
|
|
if (!select) return;
|
|
|
|
rebuildQualityPicker();
|
|
select.value = getTrackQuality();
|
|
updateQualitySelectStyle(select);
|
|
|
|
select.addEventListener("change", () => {
|
|
const q = select.value;
|
|
setTrackQuality(q);
|
|
updateQualitySelectStyle(select);
|
|
const advanced = getAdvancedMode();
|
|
if (advanced && downloadQueue.length > 0) {
|
|
if (confirm(`Apply "${QUALITY_LABELS[q] || q}" quality to all ${downloadQueue.length} item(s) in queue?`)) {
|
|
downloadQueue.forEach((item) => { item.quality = q; });
|
|
renderQueue();
|
|
}
|
|
} else {
|
|
// Normal mode: quality is read dynamically from the select for all items
|
|
renderQueue();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Sync the downloads-panel UI to reflect the current all-qualities mode state.
|
|
* - Disables/re-enables the main quality picker.
|
|
* - Shows or hides the all-qualities info banner.
|
|
* - Re-renders the queue to update per-item quality indicators.
|
|
*/
|
|
function updateAllQualitiesUI() {
|
|
const on = getAllQualitiesMode();
|
|
const select = $("quality-select");
|
|
const banner = $("all-qualities-banner");
|
|
const qualityWrap = select?.closest(".quality-picker-wrap");
|
|
|
|
if (select) {
|
|
select.disabled = on;
|
|
if (on) {
|
|
select.dataset.quality = "";
|
|
} else {
|
|
updateQualitySelectStyle(select);
|
|
}
|
|
}
|
|
if (qualityWrap) {
|
|
qualityWrap.classList.toggle("quality-picker-disabled", on);
|
|
}
|
|
if (banner) {
|
|
banner.classList.toggle("hidden", !on);
|
|
}
|
|
renderQueue();
|
|
}
|
|
|
|
// ─── Download queue ───────────────────────────────────────────────────────────
|
|
|
|
const downloadQueue = [];
|
|
|
|
const CARD_QUEUE_BTN_PLUS = `<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
|
|
const LIBRARY_TRACK_ADD_TEXT = "+ Add";
|
|
|
|
function renderQueue() {
|
|
const queueEl = $("download-queue");
|
|
const section = $("download-queue-section");
|
|
const countEl = $("queue-count");
|
|
if (!queueEl) return;
|
|
|
|
if (downloadQueue.length === 0) {
|
|
hide(section);
|
|
queueEl.innerHTML = "";
|
|
updateSearchQueueBanner();
|
|
return;
|
|
}
|
|
|
|
show(section);
|
|
if (countEl) countEl.textContent = String(downloadQueue.length);
|
|
|
|
const advanced = getAdvancedMode();
|
|
const allQualities = getAllQualitiesMode();
|
|
|
|
queueEl.innerHTML = downloadQueue.map((item, idx) => {
|
|
const thumbHtml = item.cover
|
|
? `<img src="${escHtml(item.cover)}" alt="" class="queue-item-thumb${item.coverRound ? " round" : ""}" loading="lazy" />`
|
|
: `<div class="queue-item-thumb${item.coverRound ? " round" : ""}"></div>`;
|
|
|
|
const q = item.quality || getSelectedQuality();
|
|
let qualityHtml;
|
|
if (allQualities) {
|
|
// In all-qualities mode, show a pill indicating all tiers will be downloaded
|
|
qualityHtml = `<span class="quality-pill-sm all-qualities-pill">All Qualities</span>`;
|
|
} else if (advanced) {
|
|
qualityHtml = `<select class="queue-item-quality-sel" data-idx="${idx}" data-quality="${escHtml(q)}" aria-label="Quality for this item">
|
|
${qualityOptions(q, false, item.maxQuality || null)}
|
|
</select>`;
|
|
} else {
|
|
// In normal mode, show the quality that will actually be downloaded.
|
|
// When the global quality exceeds the item's known max, cap it.
|
|
const displayQ = effectiveDownloadQuality(item, q);
|
|
qualityHtml = `<span class="quality-pill-sm" data-quality="${escHtml(displayQ)}">${escHtml(QUALITY_LABELS[displayQ] || displayQ)}</span>`;
|
|
}
|
|
|
|
return `<div class="queue-item" data-idx="${idx}">
|
|
${thumbHtml}
|
|
<div class="queue-item-info">
|
|
<div class="queue-item-title">${escHtml(item.title)}</div>
|
|
<div class="queue-item-sub">${escHtml(item.sub)}</div>
|
|
</div>
|
|
<span class="queue-item-badge" data-type="${escHtml(item.type)}">${escHtml(item.type)}</span>
|
|
${qualityHtml}
|
|
<span class="queue-item-status" id="queue-status-${idx}"></span>
|
|
<button class="queue-remove-btn" data-idx="${idx}" title="Remove" aria-label="Remove">
|
|
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
|
</button>
|
|
</div>`;
|
|
}).join("");
|
|
|
|
queueEl.querySelectorAll(".queue-remove-btn").forEach((btn) => {
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
removeFromQueue(parseInt(btn.dataset.idx, 10));
|
|
});
|
|
});
|
|
|
|
if (advanced) {
|
|
queueEl.querySelectorAll(".queue-item-quality-sel").forEach((sel) => {
|
|
sel.addEventListener("change", () => {
|
|
const idx = parseInt(sel.dataset.idx, 10);
|
|
if (downloadQueue[idx]) {
|
|
downloadQueue[idx].quality = sel.value;
|
|
sel.dataset.quality = sel.value;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
updateSearchQueueBanner();
|
|
updateQualityPickerAvailability();
|
|
}
|
|
|
|
function addToQueue(item) {
|
|
const exists = downloadQueue.some(
|
|
(q) => q.type === item.type && String(q.id) === String(item.id)
|
|
);
|
|
if (exists) return false;
|
|
// In advanced mode, set quality on the item so it can be overridden per-item.
|
|
// In normal mode, leave quality unset so the global selector always applies.
|
|
if (getAdvancedMode()) {
|
|
item.quality = item.quality || getSelectedQuality();
|
|
} else {
|
|
item.quality = undefined;
|
|
}
|
|
downloadQueue.push(item);
|
|
renderQueue();
|
|
return true;
|
|
}
|
|
|
|
function removeFromQueue(idx) {
|
|
const item = downloadQueue[idx];
|
|
downloadQueue.splice(idx, 1);
|
|
renderQueue();
|
|
|
|
if (item) {
|
|
// Remove URL pill if it came from URL input
|
|
if (item.fromUrl) {
|
|
document.querySelectorAll(`.url-pill[data-type="${item.type}"][data-id="${item.id}"]`)
|
|
.forEach((p) => p.remove());
|
|
}
|
|
// Deselect search/library result cards and reset their queue button
|
|
document.querySelectorAll(`.result-card[data-type="${item.type}"][data-id="${item.id}"]`)
|
|
.forEach((c) => {
|
|
c.classList.remove("selected"); c.setAttribute("aria-pressed", "false");
|
|
const qaBtn = c.querySelector(".card-queue-btn");
|
|
if (qaBtn) {
|
|
qaBtn.classList.remove("in-queue");
|
|
qaBtn.innerHTML = CARD_QUEUE_BTN_PLUS;
|
|
}
|
|
});
|
|
// Deselect detail track items
|
|
document.querySelectorAll(`.detail-track-item[data-id="${item.id}"]`)
|
|
.forEach((c) => c.classList.remove("selected"));
|
|
// Deselect library track items and reset their add button
|
|
document.querySelectorAll(`.library-track-item[data-id="${item.id}"]`)
|
|
.forEach((c) => {
|
|
c.classList.remove("selected");
|
|
const btn = c.querySelector(".library-track-add");
|
|
if (btn) btn.textContent = LIBRARY_TRACK_ADD_TEXT;
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearQueue() {
|
|
downloadQueue.length = 0;
|
|
document.querySelectorAll(".result-card.selected").forEach((c) => {
|
|
c.classList.remove("selected"); c.setAttribute("aria-pressed", "false");
|
|
const qaBtn = c.querySelector(".card-queue-btn");
|
|
if (qaBtn) {
|
|
qaBtn.classList.remove("in-queue");
|
|
qaBtn.innerHTML = CARD_QUEUE_BTN_PLUS;
|
|
}
|
|
});
|
|
document.querySelectorAll(".url-pill").forEach((p) => p.remove());
|
|
document.querySelectorAll(".detail-track-item.selected").forEach((c) => c.classList.remove("selected"));
|
|
document.querySelectorAll(".library-track-item.selected").forEach((c) => {
|
|
c.classList.remove("selected");
|
|
const btn = c.querySelector(".library-track-add");
|
|
if (btn) btn.textContent = LIBRARY_TRACK_ADD_TEXT;
|
|
});
|
|
renderQueue();
|
|
}
|
|
|
|
// ─── Search queue banner ──────────────────────────────────────────────────────
|
|
|
|
function updateSearchQueueBanner() {
|
|
const banner = $("search-queue-banner");
|
|
const txt = $("search-queue-banner-text");
|
|
if (!banner) return;
|
|
const n = downloadQueue.length;
|
|
if (n > 0) {
|
|
if (txt) txt.textContent = `${n} item${n === 1 ? "" : "s"} in queue`;
|
|
show(banner);
|
|
} else {
|
|
hide(banner);
|
|
}
|
|
}
|
|
|
|
// ─── Progress ─────────────────────────────────────────────────────────────────
|
|
|
|
function setProgress(done, total, message) {
|
|
const bar = $("progress-bar");
|
|
const label = $("progress-label");
|
|
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
if (bar) bar.style.width = `${pct}%`;
|
|
if (label) label.textContent = message || `${pct}%`;
|
|
}
|
|
|
|
// ─── Download ─────────────────────────────────────────────────────────────────
|
|
|
|
function expandStatusBar() {
|
|
const statusBar = $("dl-status-bar");
|
|
const toggleButton = $("btn-toggle-status-bar");
|
|
if (statusBar && statusBar.classList.contains("collapsed")) {
|
|
statusBar.classList.remove("collapsed");
|
|
if (toggleButton) {
|
|
toggleButton.setAttribute("aria-expanded", "true");
|
|
toggleButton.setAttribute("aria-label", "Collapse status bar");
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handleDownload() {
|
|
|
|
|
|
if (!isLoggedIn()) {
|
|
appendLog("You must be logged in to download.", "error");
|
|
activateTab("auth");
|
|
return;
|
|
}
|
|
|
|
// Flush any half-typed pill input
|
|
const pilInput = $("url-pill-input");
|
|
if (pilInput?.value?.trim()) {
|
|
addUrlPill(pilInput.value.trim());
|
|
pilInput.value = "";
|
|
}
|
|
|
|
const items = [...downloadQueue];
|
|
if (items.length === 0) {
|
|
appendLog("Add URLs or select items from Search to build your download queue.", "warn");
|
|
return;
|
|
}
|
|
|
|
const globalQuality = getSelectedQuality();
|
|
const advanced = getAdvancedMode();
|
|
const allQualitiesMode = getAllQualitiesMode();
|
|
const downloadBtn = $("btn-download");
|
|
downloadBtn.disabled = true;
|
|
clearLog();
|
|
// Expand the pinned status bar so progress is immediately visible
|
|
expandStatusBar();
|
|
switchDlTab("progress");
|
|
buildProgressList(items);
|
|
setProgress(0, items.length, "Starting\u2026");
|
|
|
|
let totalDone = 0, totalOk = 0, totalFail = 0;
|
|
|
|
for (let qi = 0; qi < items.length; qi++) {
|
|
const item = items[qi];
|
|
const requestedQuality = advanced ? (item.quality || globalQuality) : globalQuality;
|
|
// When FLAC is the preferred format, upgrade M4A quality tiers (LOW/HIGH) to LOSSLESS
|
|
// so the download always produces a lossless file.
|
|
const preferFlac = getPreferredFormat() === "flac";
|
|
const flacUpgraded = preferFlac && (requestedQuality === "LOW" || requestedQuality === "HIGH");
|
|
const formatQuality = flacUpgraded ? "LOSSLESS" : requestedQuality;
|
|
// Cap to the item's known max quality. When the FLAC format preference caused
|
|
// an upgrade to LOSSLESS, skip the cap so a stale quality hint from the
|
|
// search/library API (which can differ across devices) does not silently
|
|
// downgrade the request back to a lossy tier.
|
|
const quality = flacUpgraded ? formatQuality : effectiveDownloadQuality(item, formatQuality);
|
|
const qStatusEl = $(`queue-status-${qi}`);
|
|
const queueItemEl = $("download-queue")?.querySelector(`.queue-item[data-idx="${qi}"]`);
|
|
const itemRowEl = $(`dl-item-row-${qi}`);
|
|
const itemMsgEl = $(`dl-item-msg-${qi}`);
|
|
const itemBarEl = $(`dl-item-bar-${qi}`);
|
|
const itemIconEl = $(`dl-item-icon-${qi}`);
|
|
|
|
if (queueItemEl) queueItemEl.classList.add("q-downloading");
|
|
if (itemRowEl) {
|
|
itemRowEl.classList.remove("dl-item-row--pending");
|
|
itemRowEl.classList.add("dl-item-row--active");
|
|
}
|
|
|
|
if (allQualitiesMode) {
|
|
appendLog(`Downloading ${item.type}/${item.id} in all quality tiers`, "info");
|
|
} else {
|
|
appendLog(`Downloading ${item.type}/${item.id} @ ${QUALITY_LABELS[quality] || quality}`, "info");
|
|
}
|
|
if (qStatusEl) qStatusEl.textContent = "\u23f3";
|
|
if (itemIconEl) itemIconEl.textContent = "\u23f3";
|
|
|
|
const onProgress = (done, total, msg) => {
|
|
setProgress(qi, items.length, msg || `Item ${qi + 1}/${items.length}`);
|
|
if (msg) appendLog(msg, "info");
|
|
if (itemBarEl && total > 0) itemBarEl.style.width = `${Math.round((done / total) * 100)}%`;
|
|
if (itemMsgEl && msg) itemMsgEl.textContent = msg;
|
|
};
|
|
|
|
const isMulti = ["album", "playlist", "mix", "artist"].includes(item.type);
|
|
const onSubItemProgress = isMulti
|
|
? (subIdx, subTotal, title, done, total, status) => {
|
|
upsertSubItemRow(qi, subIdx, title, done, total, status);
|
|
}
|
|
: undefined;
|
|
|
|
try {
|
|
let results = [];
|
|
|
|
// Determine which quality tiers to download
|
|
const tiersToDownload = allQualitiesMode ? QUALITY_STANDARD : [quality];
|
|
|
|
for (const tier of tiersToDownload) {
|
|
const tierQuality = allQualitiesMode ? effectiveDownloadQuality(item, tier) : tier;
|
|
const nameSuffix = allQualitiesMode ? ` [${QUALITY_LABELS[tier] || tier}]` : "";
|
|
if (allQualitiesMode) {
|
|
appendLog(` \u21b3 ${QUALITY_LABELS[tier] || tier}`, "info");
|
|
}
|
|
let tierResults = [];
|
|
switch (item.type) {
|
|
case "track": tierResults = [await downloadTrack(item.id, tierQuality, onProgress, nameSuffix)]; break;
|
|
case "album": tierResults = await downloadAlbum(item.id, tierQuality, onProgress, onSubItemProgress, nameSuffix); break;
|
|
case "playlist": tierResults = await downloadPlaylist(item.id, tierQuality, onProgress, onSubItemProgress, nameSuffix); break;
|
|
case "mix": tierResults = await downloadMix(item.id, tierQuality, onProgress, onSubItemProgress, nameSuffix); break;
|
|
case "artist": tierResults = await downloadArtistAlbums(item.id, tierQuality, onProgress, onSubItemProgress, nameSuffix); break;
|
|
default:
|
|
appendLog(`Resource type "${item.type}" not yet supported in the browser.`, "warn");
|
|
tierResults = [{ filename: String(item.id), success: false, error: "unsupported type" }];
|
|
}
|
|
results = results.concat(tierResults);
|
|
}
|
|
|
|
for (const r of results) {
|
|
if (r.success) { appendLog(`\u2713 Saved: ${r.filename}`, "success"); totalOk++; }
|
|
else { appendLog(`\u2717 Failed: ${r.filename} \u2014 ${r.error}`, "error"); totalFail++; }
|
|
}
|
|
const allOk = results.every((r) => r.success);
|
|
if (qStatusEl) qStatusEl.textContent = allOk ? "\u2713" : "\u2717";
|
|
if (itemIconEl) itemIconEl.textContent = allOk ? "\u2713" : "\u2717";
|
|
if (itemBarEl) itemBarEl.style.width = "100%";
|
|
if (itemMsgEl) itemMsgEl.textContent = allOk ? "Done" : `Failed \u2014 ${results.filter(r => !r.success).map(r => r.error).join(", ")}`;
|
|
if (itemRowEl) {
|
|
itemRowEl.classList.remove("dl-item-row--active");
|
|
itemRowEl.classList.add(allOk ? "dl-item-row--done" : "dl-item-row--failed");
|
|
}
|
|
if (queueItemEl) {
|
|
queueItemEl.classList.remove("q-downloading");
|
|
queueItemEl.classList.add(allOk ? "q-done" : "q-failed");
|
|
}
|
|
// Auto-remove successful items from queue after a brief delay.
|
|
// Uses indexOf to find the item by reference; if it was already
|
|
// removed by the user in the meantime indexOf returns -1 and we
|
|
// skip the removal safely.
|
|
if (allOk) {
|
|
const itemRef = item;
|
|
setTimeout(() => {
|
|
const idx = downloadQueue.indexOf(itemRef);
|
|
if (idx !== -1) removeFromQueue(idx);
|
|
}, 1500);
|
|
}
|
|
} catch (err) {
|
|
appendLog(`Error (${item.type}/${item.id}): ${err.message}`, "error");
|
|
if (qStatusEl) qStatusEl.textContent = "\u2717";
|
|
if (itemIconEl) itemIconEl.textContent = "\u2717";
|
|
if (itemBarEl) itemBarEl.style.width = "100%";
|
|
if (itemMsgEl) itemMsgEl.textContent = `Error: ${err.message}`;
|
|
if (itemRowEl) { itemRowEl.classList.remove("dl-item-row--active"); itemRowEl.classList.add("dl-item-row--failed"); }
|
|
if (queueItemEl) { queueItemEl.classList.remove("q-downloading"); queueItemEl.classList.add("q-failed"); }
|
|
totalFail++;
|
|
}
|
|
totalDone++;
|
|
setProgress(totalDone, items.length, `${totalDone}/${items.length} done`);
|
|
}
|
|
|
|
setProgress(items.length, items.length,
|
|
totalFail === 0 ? "Done \u2713" : `Done \u2014 ${totalOk} ok, ${totalFail} failed`);
|
|
downloadBtn.disabled = false;
|
|
}
|
|
|
|
// ─── URL pill input ───────────────────────────────────────────────────────────
|
|
|
|
function addUrlPill(raw) {
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return false;
|
|
|
|
const resource = parseTidalInput(trimmed);
|
|
const isValid = !!resource;
|
|
const label = resource ? `${resource.type}/${resource.id}` : trimmed;
|
|
|
|
if (isValid) {
|
|
const added = addToQueue({
|
|
type: resource.type, id: resource.id,
|
|
title: label, sub: trimmed !== label ? trimmed : "",
|
|
cover: "", coverRound: false, fromUrl: true,
|
|
});
|
|
if (!added) return false; // duplicate
|
|
enrichQueueItemFromUrl(resource.type, resource.id);
|
|
}
|
|
|
|
const pillBox = $("url-pill-box");
|
|
const input = $("url-pill-input");
|
|
const pill = document.createElement("span");
|
|
pill.className = `url-pill${isValid ? "" : " invalid"}`;
|
|
pill.dataset.type = resource?.type || "";
|
|
pill.dataset.id = resource?.id || "";
|
|
pill.setAttribute("role", "listitem");
|
|
pill.innerHTML = `<span class="url-pill-label" title="${escHtml(trimmed)}">${escHtml(label)}</span>
|
|
<button class="url-pill-remove" aria-label="Remove ${escHtml(label)}">
|
|
<svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M18 6 6 18M6 6l12 12"/></svg>
|
|
</button>`;
|
|
|
|
pill.querySelector(".url-pill-remove").addEventListener("click", () => {
|
|
if (isValid && resource) {
|
|
const idx = downloadQueue.findIndex(
|
|
(q) => q.type === resource.type && String(q.id) === String(resource.id)
|
|
);
|
|
if (idx !== -1) removeFromQueue(idx);
|
|
}
|
|
pill.remove();
|
|
});
|
|
|
|
pillBox.insertBefore(pill, input);
|
|
|
|
if (!isValid) {
|
|
appendLog(`Could not parse "${trimmed}" as a Tidal URL.`, "warn");
|
|
}
|
|
return isValid;
|
|
}
|
|
|
|
/**
|
|
* After adding a URL-based queue item, asynchronously fetch its real metadata
|
|
* (title, artist, cover) from the API and update the queue display.
|
|
*/
|
|
async function enrichQueueItemFromUrl(type, id) {
|
|
if (!isLoggedIn()) return;
|
|
try {
|
|
let title = "", sub = "", cover = "", coverRound = false, maxQuality = null;
|
|
if (type === "track") {
|
|
const data = await getTrack(id);
|
|
title = data.title || "";
|
|
sub = data.artist?.name || "";
|
|
cover = coverUrl(data.album?.cover);
|
|
maxQuality = effectiveQuality(data) || null;
|
|
} else if (type === "album") {
|
|
const data = await getAlbum(id);
|
|
title = data.title || "";
|
|
sub = data.artist?.name || "";
|
|
cover = coverUrl(data.cover);
|
|
maxQuality = effectiveQuality(data) || null;
|
|
} else if (type === "playlist") {
|
|
const data = await getPlaylist(id);
|
|
title = data.title || "";
|
|
sub = data.creator?.name || "Tidal";
|
|
cover = coverUrl(data.image || data.squareImage);
|
|
} else if (type === "artist") {
|
|
const data = await getArtist(id);
|
|
title = data.name || "";
|
|
sub = "Artist";
|
|
cover = coverUrl(data.picture);
|
|
coverRound = true;
|
|
} else if (type === "video") {
|
|
const data = await getVideo(id);
|
|
title = data.title || "";
|
|
sub = data.artist?.name || "";
|
|
cover = coverUrl(data.imageId || data.album?.cover);
|
|
} else {
|
|
return; // mix — no direct metadata endpoint
|
|
}
|
|
if (!title) return;
|
|
const item = downloadQueue.find(
|
|
(q) => q.type === type && String(q.id) === String(id) && q.fromUrl
|
|
);
|
|
if (item) {
|
|
item.title = title;
|
|
item.sub = sub;
|
|
item.cover = cover;
|
|
item.coverRound = coverRound;
|
|
if (maxQuality) item.maxQuality = maxQuality;
|
|
renderQueue();
|
|
}
|
|
// Also update the URL pill label so it shows the real title instead of type/id
|
|
const pillEl = document.querySelector(`.url-pill[data-type="${CSS.escape(type)}"][data-id="${CSS.escape(String(id))}"]`);
|
|
if (pillEl) {
|
|
const labelEl = pillEl.querySelector(".url-pill-label");
|
|
if (labelEl) {
|
|
labelEl.textContent = sub ? `${title} — ${sub}` : title;
|
|
labelEl.title = sub ? `${title} — ${sub}` : title;
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently fail — the URL/ID placeholder remains visible
|
|
}
|
|
}
|
|
|
|
function initUrlPillInput() {
|
|
const input = $("url-pill-input");
|
|
const pillBox = $("url-pill-box");
|
|
if (!input || !pillBox) return;
|
|
|
|
pillBox.addEventListener("click", (e) => {
|
|
if (!e.target.closest(".url-pill")) input.focus();
|
|
});
|
|
|
|
input.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" || e.key === ",") {
|
|
e.preventDefault();
|
|
const val = input.value.trim();
|
|
if (val) { addUrlPill(val); input.value = ""; }
|
|
} else if (e.key === "Backspace" && !input.value) {
|
|
const pills = pillBox.querySelectorAll(".url-pill");
|
|
if (pills.length) {
|
|
const last = pills[pills.length - 1];
|
|
const type = last.dataset.type;
|
|
const id = last.dataset.id;
|
|
if (type && id) {
|
|
const idx = downloadQueue.findIndex(
|
|
(q) => q.type === type && String(q.id) === String(id)
|
|
);
|
|
if (idx !== -1) removeFromQueue(idx);
|
|
}
|
|
last.remove();
|
|
}
|
|
}
|
|
});
|
|
|
|
input.addEventListener("paste", (e) => {
|
|
const text = (e.clipboardData || window.clipboardData)?.getData("text");
|
|
if (!text) return;
|
|
const lines = text.split(/[\n\r,]+/).map((l) => l.trim()).filter(Boolean);
|
|
if (lines.length > 1) {
|
|
e.preventDefault();
|
|
lines.forEach((line) => addUrlPill(line));
|
|
input.value = "";
|
|
}
|
|
});
|
|
|
|
// File import
|
|
const fileInput = $("url-file-input");
|
|
const importBtn = $("btn-import-file");
|
|
importBtn?.addEventListener("click", () => fileInput?.click());
|
|
fileInput?.addEventListener("change", () => {
|
|
const file = fileInput.files[0];
|
|
if (!file) return;
|
|
const ext = (file.name.split(".").pop() || "").toLowerCase();
|
|
const reader = new FileReader();
|
|
reader.onerror = () => {
|
|
appendLog(`Could not read "${escHtml(file.name)}".`, "error");
|
|
fileInput.value = "";
|
|
};
|
|
reader.onload = (ev) => {
|
|
const text = ev.target.result || "";
|
|
const tokens = extractTidalTokens(text, ext);
|
|
let added = 0;
|
|
tokens.forEach((token) => { if (addUrlPill(token)) added++; });
|
|
if (added > 0) {
|
|
appendLog(`Imported ${added} URL(s) from "${escHtml(file.name)}".`, "info");
|
|
} else if (BINARY_OFFICE_EXTS.has(ext)) {
|
|
appendLog(
|
|
`No Tidal URLs found in "${escHtml(file.name)}". ` +
|
|
`.${ext} is a binary format — text extraction may be unreliable. ` +
|
|
`Try saving the file as plain text (.txt) first and import again.`,
|
|
"warn"
|
|
);
|
|
} else {
|
|
appendLog(`No Tidal URLs found in "${escHtml(file.name)}".`, "warn");
|
|
}
|
|
fileInput.value = "";
|
|
};
|
|
reader.readAsText(file, "utf-8");
|
|
});
|
|
}
|
|
|
|
// ─── Search ───────────────────────────────────────────────────────────────────
|
|
|
|
let lastSearchData = null;
|
|
let activeTypeFilter = "all";
|
|
const INITIAL_LIMIT = 8;
|
|
|
|
function updateSearchClearBtn() {
|
|
const input = $("search-input");
|
|
const btn = $("btn-clear-search");
|
|
if (!btn || !input) return;
|
|
btn.classList.toggle("hidden", !input.value);
|
|
}
|
|
|
|
function showSearchHistory() {
|
|
const history = loadSearchHistory();
|
|
const histEl = $("search-history");
|
|
const listEl = $("search-history-list");
|
|
if (!histEl || !listEl) return;
|
|
if (!history.length) { hide(histEl); return; }
|
|
|
|
listEl.innerHTML = history.map((q) =>
|
|
`<button class="search-history-item" data-query="${escHtml(q)}">
|
|
<svg width="11" height="11" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
|
${escHtml(q)}
|
|
</button>`
|
|
).join("");
|
|
|
|
listEl.querySelectorAll(".search-history-item").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const input = $("search-input");
|
|
if (input) input.value = btn.dataset.query;
|
|
updateSearchClearBtn();
|
|
hide(histEl);
|
|
handleSearch();
|
|
});
|
|
});
|
|
show(histEl);
|
|
}
|
|
|
|
function buildResultCard(resourceType, id, title, sub, cover, round, quality = null) {
|
|
const inQueue = downloadQueue.some(
|
|
(q) => q.type === resourceType && String(q.id) === String(id)
|
|
);
|
|
const isTrack = resourceType === "track";
|
|
|
|
const imgHtml = cover
|
|
? `<img src="${escHtml(cover)}" alt="" class="result-cover${round ? " round" : ""}" loading="lazy" />`
|
|
: `<div class="result-cover placeholder${round ? " round" : ""}"></div>`;
|
|
|
|
const overlayHtml = isTrack
|
|
? `<div class="result-card-overlay">
|
|
<span class="result-card-check">
|
|
<svg width="18" height="18" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>
|
|
</span>
|
|
</div>`
|
|
: `<div class="result-card-overlay">
|
|
<span class="result-card-nav-icon">
|
|
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24"><path d="M5 12h14m-7-7 7 7-7 7"/></svg>
|
|
</span>
|
|
<button class="card-queue-btn${inQueue ? " in-queue" : ""}" data-quick-add="true" title="Add to queue" aria-label="Add to queue">
|
|
${inQueue
|
|
? `<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`
|
|
: `<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`
|
|
}
|
|
</button>
|
|
</div>`;
|
|
|
|
return `<div class="result-card${inQueue ? " selected" : ""}${isTrack ? "" : " result-card--nav"}"
|
|
data-type="${escHtml(resourceType)}" data-id="${escHtml(String(id))}"
|
|
data-title="${escHtml(title)}" data-sub="${escHtml(sub)}"
|
|
data-cover="${escHtml(cover)}" data-round="${round}"
|
|
data-quality="${escHtml(quality || '')}"
|
|
tabindex="0" role="button" aria-pressed="${inQueue}">
|
|
<div class="result-card-img-wrap">${imgHtml}${overlayHtml}</div>
|
|
<div class="result-info">
|
|
<span class="result-title">${escHtml(title)}</span>
|
|
<span class="result-sub">${escHtml(sub)}</span>
|
|
${quality ? qualityPill(quality) : ""}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function buildResultGrid(items, type, shownCount, totalCount) {
|
|
if (!items.length) return "";
|
|
const typeLabel = type.charAt(0).toUpperCase() + type.slice(1);
|
|
let html = `<div class="result-section" data-section="${type}">
|
|
<h3 class="result-heading">${typeLabel}</h3>
|
|
<div class="result-grid">`;
|
|
|
|
for (const item of items.slice(0, shownCount)) {
|
|
let id, title, sub, cover, round = false, quality = null;
|
|
if (type === "tracks") {
|
|
id = item.id; title = formatTrackTitle(item); sub = item.artist?.name || "";
|
|
cover = coverUrl(item.album?.cover); quality = effectiveQuality(item);
|
|
} else if (type === "albums") {
|
|
id = item.id; title = item.title; sub = item.artist?.name || "";
|
|
cover = coverUrl(item.cover); quality = effectiveQuality(item);
|
|
} else if (type === "artists") {
|
|
id = item.id; title = item.name; sub = "Artist";
|
|
cover = coverUrl(item.picture); round = true;
|
|
} else if (type === "playlists") {
|
|
id = item.uuid; title = item.title; sub = item.creator?.name || "Tidal";
|
|
cover = coverUrl(item.image || item.squareImage);
|
|
}
|
|
const resourceType = type === "tracks" ? "track"
|
|
: type === "albums" ? "album"
|
|
: type === "artists" ? "artist"
|
|
: "playlist";
|
|
html += buildResultCard(resourceType, id, title, sub, cover, round, quality);
|
|
}
|
|
|
|
html += `</div>`;
|
|
if (totalCount > shownCount) {
|
|
html += `<div class="view-more-wrap">
|
|
<button class="btn-view-more" data-type="${type}" data-shown="${shownCount}">
|
|
View more (${totalCount - shownCount} more)
|
|
</button>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Build a horizontally-scrollable single-row section for a set of items.
|
|
* Used in "From your library" suggestions and artist detail panels.
|
|
* @param {Array} items - Array of Tidal resource objects
|
|
* @param {string} type - "tracks" | "albums" | "artists" | "playlists"
|
|
* @param {string} heading - Display heading text
|
|
* @param {string} viewAllKey - Unique key written to data-viewall on the button
|
|
*/
|
|
function buildResultRowSection(items, type, heading, viewAllKey) {
|
|
if (!items.length) return "";
|
|
let cardsHtml = "";
|
|
for (const item of items) {
|
|
let id, title, sub, cover, round = false, quality = null;
|
|
if (type === "tracks") {
|
|
id = item.id; title = formatTrackTitle(item); sub = item.artist?.name || "";
|
|
cover = coverUrl(item.album?.cover); quality = effectiveQuality(item);
|
|
} else if (type === "albums") {
|
|
id = item.id; title = item.title; sub = item.artist?.name || "";
|
|
cover = coverUrl(item.cover); quality = effectiveQuality(item);
|
|
} else if (type === "artists") {
|
|
id = item.id; title = item.name; sub = "Artist";
|
|
cover = coverUrl(item.picture); round = true;
|
|
} else if (type === "playlists") {
|
|
id = item.uuid; title = item.title; sub = item.creator?.name || "Tidal";
|
|
cover = coverUrl(item.image || item.squareImage);
|
|
}
|
|
const resourceType = type === "tracks" ? "track"
|
|
: type === "albums" ? "album"
|
|
: type === "artists" ? "artist"
|
|
: "playlist";
|
|
cardsHtml += buildResultCard(resourceType, id, title, sub, cover, round, quality);
|
|
}
|
|
return `<div class="result-row-section">
|
|
<div class="result-row-header">
|
|
<h3 class="result-heading">${escHtml(heading)}</h3>
|
|
<button class="btn-view-all" data-viewall="${escHtml(viewAllKey)}">View all</button>
|
|
</div>
|
|
<div class="result-row">${cardsHtml}</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderSearchResults(data, filter = "all") {
|
|
const container = $("search-results");
|
|
if (!container) return;
|
|
|
|
const tracks = data?.tracks?.items || [];
|
|
const albums = data?.albums?.items || [];
|
|
const artists = data?.artists?.items || [];
|
|
const playlists = data?.playlists?.items || [];
|
|
|
|
if (!tracks.length && !albums.length && !artists.length && !playlists.length) {
|
|
container.innerHTML = '<p class="no-results">No results found.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = "";
|
|
const showType = (t) => filter === "all" || filter === t;
|
|
if (showType("tracks")) html += buildResultGrid(tracks, "tracks", INITIAL_LIMIT, tracks.length);
|
|
if (showType("albums")) html += buildResultGrid(albums, "albums", INITIAL_LIMIT, albums.length);
|
|
if (showType("artists")) html += buildResultGrid(artists, "artists", INITIAL_LIMIT, artists.length);
|
|
if (showType("playlists")) html += buildResultGrid(playlists, "playlists", INITIAL_LIMIT, playlists.length);
|
|
|
|
container.innerHTML = html || '<p class="no-results">No results for this category.</p>';
|
|
attachResultHandlers(container);
|
|
}
|
|
|
|
function attachResultHandlers(container) {
|
|
container.querySelectorAll(".result-card").forEach((card) => {
|
|
const isTrack = card.dataset.type === "track";
|
|
if (isTrack) {
|
|
card.addEventListener("click", () => toggleResultInQueue(card));
|
|
card.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggleResultInQueue(card); }
|
|
});
|
|
} else {
|
|
// Main card click -> detail
|
|
card.addEventListener("click", (e) => {
|
|
if (e.target.closest(".card-queue-btn")) return;
|
|
navigateToDetail(card.dataset.type, card.dataset.id,
|
|
card.dataset.title, card.dataset.sub, card.dataset.cover, _detailOriginTab,
|
|
card.dataset.quality || null);
|
|
});
|
|
card.addEventListener("keydown", (e) => {
|
|
if (e.key === "Enter") {
|
|
navigateToDetail(card.dataset.type, card.dataset.id,
|
|
card.dataset.title, card.dataset.sub, card.dataset.cover, _detailOriginTab,
|
|
card.dataset.quality || null);
|
|
}
|
|
});
|
|
// Quick-add button
|
|
const qaBtn = card.querySelector(".card-queue-btn");
|
|
if (qaBtn) {
|
|
qaBtn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
toggleResultInQueue(card);
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
container.querySelectorAll(".btn-view-more").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const type = btn.dataset.type;
|
|
const shown = parseInt(btn.dataset.shown, 10);
|
|
const items = lastSearchData?.[type]?.items || [];
|
|
const newShown = shown + INITIAL_LIMIT;
|
|
const sectionEl = container.querySelector(`.result-section[data-section="${type}"]`);
|
|
if (sectionEl) {
|
|
const tmp = document.createElement("div");
|
|
tmp.innerHTML = buildResultGrid(items, type, newShown, items.length);
|
|
sectionEl.replaceWith(tmp.firstElementChild);
|
|
attachResultHandlers(container);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function toggleResultInQueue(card) {
|
|
const type = card.dataset.type;
|
|
const id = card.dataset.id;
|
|
const title = card.dataset.title;
|
|
const sub = card.dataset.sub;
|
|
const cover = card.dataset.cover;
|
|
const round = card.dataset.round === "true";
|
|
const maxQuality = card.dataset.quality || null;
|
|
|
|
const existing = downloadQueue.findIndex(
|
|
(q) => q.type === type && String(q.id) === String(id)
|
|
);
|
|
|
|
if (existing !== -1) {
|
|
removeFromQueue(existing);
|
|
} else {
|
|
addToQueue({ type, id, title, sub, cover, coverRound: round, maxQuality });
|
|
card.classList.add("selected");
|
|
card.setAttribute("aria-pressed", "true");
|
|
// Update quick-add button
|
|
const qaBtn = card.querySelector(".card-queue-btn");
|
|
if (qaBtn) {
|
|
qaBtn.classList.add("in-queue");
|
|
qaBtn.innerHTML = `<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="3" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></svg>`;
|
|
}
|
|
appendLog(`Added to queue: ${type}/${id} \u2014 "${title}"`, "info");
|
|
if (type === "track") activateTab("download");
|
|
}
|
|
}
|
|
|
|
async function handleSearch() {
|
|
if (!isLoggedIn()) {
|
|
appendLog("You must be logged in to search.", "error");
|
|
activateTab("auth");
|
|
return;
|
|
}
|
|
const query = $("search-input")?.value?.trim();
|
|
if (!query) return;
|
|
|
|
saveToSearchHistory(query);
|
|
hide($("search-history"));
|
|
|
|
const searchBtn = $("btn-search");
|
|
searchBtn.disabled = true;
|
|
const container = $("search-results");
|
|
if (container) container.innerHTML = '<p class="searching"><span class="spinner"></span> Searching\u2026</p>';
|
|
|
|
try {
|
|
const data = await search(query, 20);
|
|
lastSearchData = data;
|
|
renderSearchResults(data, activeTypeFilter);
|
|
} catch (err) {
|
|
if (container)
|
|
container.innerHTML = `<p class="error">Search failed: ${escHtml(err.message)}</p>`;
|
|
} finally {
|
|
searchBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function showSearchSuggestions() {
|
|
if (!isLoggedIn()) return;
|
|
const container = $("search-results");
|
|
if (!container || container.children.length) return; // already has content
|
|
|
|
container.innerHTML = '<p class="searching"><span class="spinner"></span> Loading your library\u2026</p>';
|
|
try {
|
|
const [tracksData, albumsData] = await Promise.all([
|
|
getUserFavoriteTracks(8, 0).catch(() => ({ items: [] })),
|
|
getUserFavoriteAlbums(8, 0).catch(() => ({ items: [] })),
|
|
]);
|
|
const tracks = (tracksData.items || []).map((i) => i.item || i);
|
|
const albums = (albumsData.items || []).map((i) => i.item || i);
|
|
if (!tracks.length && !albums.length) {
|
|
container.innerHTML = '<p class="no-results">Search for tracks, albums, artists or playlists above.</p>';
|
|
return;
|
|
}
|
|
let html = '<h3 class="result-heading" style="margin-top:0">From your library</h3>';
|
|
if (tracks.length) html += buildResultRowSection(tracks, "tracks", "Tracks", "lib-tracks");
|
|
if (albums.length) html += buildResultRowSection(albums, "albums", "Albums", "lib-albums");
|
|
container.innerHTML = html;
|
|
attachResultHandlers(container);
|
|
// "View all" navigates to the Library tab with the matching section
|
|
const goToLibrarySection = (section) => {
|
|
_librarySection = section;
|
|
document.querySelectorAll("#library-section-pills .type-pill").forEach((p) =>
|
|
p.classList.toggle("active", p.dataset.section === section)
|
|
);
|
|
activateTab("library");
|
|
loadLibraryIfNeeded();
|
|
};
|
|
container.querySelector('[data-viewall="lib-tracks"]')?.addEventListener("click", () => goToLibrarySection("tracks"));
|
|
container.querySelector('[data-viewall="lib-albums"]')?.addEventListener("click", () => goToLibrarySection("albums"));
|
|
} catch {
|
|
container.innerHTML = '<p class="no-results">Search for tracks, albums, artists or playlists above.</p>';
|
|
}
|
|
}
|
|
|
|
// ─── Detail panel ─────────────────────────────────────────────────────────────
|
|
|
|
/** Build the inner HTML for the detail queue button, reflecting current queue state. */
|
|
function _detailQueueBtnHtml(typeLabel, isInQueue) {
|
|
if (isInQueue) {
|
|
return `<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="margin-right:4px;vertical-align:-2px"><polyline points="20 6 9 17 4 12"/></svg>
|
|
${escHtml(typeLabel)} in Queue`;
|
|
}
|
|
return `<svg width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.5" viewBox="0 0 24 24" style="margin-right:4px;vertical-align:-2px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
|
Add ${escHtml(typeLabel)} to Queue`;
|
|
}
|
|
|
|
async function navigateToDetail(type, id, title, sub, cover, fromTab, hintQuality = null, skipStackPush = false) {
|
|
if (skipStackPush) {
|
|
// Called by backFromDetail — stack already popped; skip push.
|
|
} else if (_currentDetail !== null) {
|
|
// Navigating deeper from within the detail panel; save current state.
|
|
_detailNavStack.push({ ..._currentDetail });
|
|
} else {
|
|
// Fresh navigation from a tab; reset the stack and origin.
|
|
_detailNavStack = [];
|
|
_detailOriginTab = fromTab || "search";
|
|
}
|
|
_currentDetail = { type, id, title, sub, cover, hintQuality };
|
|
openDetailPanel(fromTab);
|
|
|
|
const header = $("detail-header");
|
|
const body = $("detail-body");
|
|
const round = type === "artist";
|
|
const inQueue = downloadQueue.some((q) => q.type === type && String(q.id) === String(id));
|
|
|
|
const typeLabel = { album: "Album", artist: "Artist", playlist: "Playlist", mix: "Mix" }[type] || type;
|
|
const largerCover = cover.replace(/320x320/, "640x640");
|
|
|
|
header.innerHTML = `
|
|
<div class="detail-cover-wrap">
|
|
${largerCover
|
|
? `<img src="${escHtml(largerCover)}" alt="" class="detail-cover${round ? " round" : ""}" loading="lazy"/>`
|
|
: `<div class="detail-cover placeholder${round ? " round" : ""}"></div>`}
|
|
</div>
|
|
<div class="detail-header-info">
|
|
<div class="detail-type-row">
|
|
<span class="detail-type-badge">${escHtml(typeLabel)}</span>
|
|
<span id="detail-header-quality"></span>
|
|
</div>
|
|
<h2 class="detail-title">${escHtml(title)}</h2>
|
|
<p class="detail-sub" id="detail-sub">${escHtml(sub)}</p>
|
|
<div class="detail-header-meta" id="detail-header-meta"></div>
|
|
<div class="detail-actions">
|
|
<button class="${inQueue ? "btn-secondary in-queue" : "btn-primary"}" id="btn-detail-dl-queue">
|
|
${_detailQueueBtnHtml(typeLabel, inQueue)}
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
|
|
// Ensure the in-queue class is applied if needed (querySelector after innerHTML)
|
|
const dlQueueBtn = header.querySelector("#btn-detail-dl-queue");
|
|
|
|
dlQueueBtn?.addEventListener("click", () => {
|
|
const existing = downloadQueue.findIndex(
|
|
(q) => q.type === type && String(q.id) === String(id)
|
|
);
|
|
if (existing !== -1) {
|
|
removeFromQueue(existing);
|
|
dlQueueBtn.className = "btn-primary";
|
|
dlQueueBtn.innerHTML = _detailQueueBtnHtml(typeLabel, false);
|
|
appendLog(`Removed ${type}/${id} from queue.`, "info");
|
|
} else {
|
|
const added = addToQueue({ type, id, title, sub, cover, coverRound: round });
|
|
if (added) {
|
|
dlQueueBtn.className = "btn-secondary in-queue";
|
|
dlQueueBtn.innerHTML = _detailQueueBtnHtml(typeLabel, true);
|
|
appendLog(`Added ${type}/${id} to queue.`, "info");
|
|
}
|
|
}
|
|
});
|
|
|
|
body.innerHTML = '<p class="searching"><span class="spinner"></span> Loading\u2026</p>';
|
|
|
|
try {
|
|
if (type === "artist") {
|
|
await renderArtistDetail(id, title, body);
|
|
} else if (type === "album") {
|
|
await renderAlbumDetail(id, body, hintQuality);
|
|
} else if (type === "playlist") {
|
|
await renderPlaylistDetail(id, body);
|
|
}
|
|
} catch (err) {
|
|
body.innerHTML = `<p class="error">Failed to load: ${escHtml(err.message)}</p>`;
|
|
}
|
|
}
|
|
|
|
async function renderArtistDetail(artistId, artistName, body) {
|
|
const [albumsData, singlesData] = await Promise.all([
|
|
getArtistAlbums(artistId, 20, 0).catch(() => ({ items: [] })),
|
|
getArtistSingles(artistId, 20, 0).catch(() => ({ items: [] })),
|
|
]);
|
|
const albums = albumsData.items || [];
|
|
const singles = singlesData.items || [];
|
|
|
|
function makeAlbumCards(items) {
|
|
return items.map((album) => {
|
|
const c = coverUrl(album.cover);
|
|
return buildResultCard("album", album.id, album.title, album.releaseDate?.slice(0,4) || "", c, false, effectiveQuality(album));
|
|
}).join("");
|
|
}
|
|
|
|
function buildArtistSection(items, heading, sectionKey) {
|
|
if (!items.length) return "";
|
|
const cardsHtml = makeAlbumCards(items);
|
|
return `<div class="artist-release-section" data-section="${escHtml(sectionKey)}">
|
|
<div class="result-row-header">
|
|
<p class="detail-section-heading" style="margin:0">${escHtml(heading)}</p>
|
|
<button class="btn-view-all" data-viewall="${escHtml(sectionKey)}">View all (${items.length})</button>
|
|
</div>
|
|
<div class="result-row">${cardsHtml}</div>
|
|
<div class="detail-album-grid hidden">${cardsHtml}</div>
|
|
</div>`;
|
|
}
|
|
|
|
let html = "";
|
|
if (albums.length) html += buildArtistSection(albums, "Albums", "albums");
|
|
if (singles.length) html += buildArtistSection(singles, "Singles & EPs", "singles");
|
|
if (!html) html = `<p class="no-results">No releases found for this artist.</p>`;
|
|
|
|
body.innerHTML = html;
|
|
|
|
// "View all" swaps the horizontal row for the full grid
|
|
body.querySelectorAll(".artist-release-section .btn-view-all").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const section = btn.closest(".artist-release-section");
|
|
if (!section) return;
|
|
section.querySelector(".result-row")?.classList.add("hidden");
|
|
section.querySelector(".detail-album-grid")?.classList.remove("hidden");
|
|
btn.remove();
|
|
});
|
|
});
|
|
|
|
attachResultHandlers(body);
|
|
}
|
|
|
|
async function renderAlbumDetail(albumId, body, hintQuality = null) {
|
|
const [albumMeta, allItems] = await Promise.all([
|
|
getAlbum(albumId),
|
|
getAllAlbumItems(albumId),
|
|
]);
|
|
const tracks = (allItems || []).filter((i) => i.type === "track").map((i) => i.item);
|
|
|
|
// Determine the best available quality from all sources: album metadata,
|
|
// individual track qualities, and the hint carried from the search result or
|
|
// library card that opened this view (search/library endpoints sometimes
|
|
// return more accurate quality data than the album endpoint itself).
|
|
const albumMetaQuality = effectiveQuality(albumMeta);
|
|
const trackQualities = new Set(tracks.map((t) => effectiveQuality(t)).filter(Boolean));
|
|
const bestTrackQuality = QUALITY_ORDER.find((q) => trackQualities.has(q)) || null;
|
|
const albumQuality = QUALITY_ORDER.find((q) =>
|
|
q === albumMetaQuality || q === bestTrackQuality || q === hintQuality
|
|
) || null;
|
|
|
|
// Make the artist name in the header a link to the artist's detail page
|
|
const artistId = albumMeta.artist?.id;
|
|
const artistName = albumMeta.artist?.name || "";
|
|
const artistCover = coverUrl(albumMeta.artist?.picture);
|
|
const subEl = $("detail-sub");
|
|
if (subEl && artistId) {
|
|
subEl.innerHTML = "";
|
|
const btn = document.createElement("button");
|
|
btn.className = "detail-artist-link";
|
|
btn.textContent = artistName;
|
|
btn.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
navigateToDetail("artist", artistId, artistName, "", artistCover, _detailOriginTab);
|
|
});
|
|
subEl.appendChild(btn);
|
|
}
|
|
|
|
// Update the detail header quality pill with the album's best quality
|
|
const headerQualityEl = $("detail-header-quality");
|
|
if (headerQualityEl) headerQualityEl.innerHTML = qualityPill(albumQuality);
|
|
|
|
// Populate the metadata row: track count, duration, year
|
|
const headerMetaEl = $("detail-header-meta");
|
|
if (headerMetaEl) {
|
|
const year = albumMeta.releaseDate ? albumMeta.releaseDate.slice(0, 4) : null;
|
|
const trackCount = albumMeta.numberOfTracks || tracks.length;
|
|
const totalDuration = albumMeta.duration
|
|
? formatDuration(albumMeta.duration)
|
|
: tracks.reduce((sum, t) => sum + (t.duration || 0), 0) > 0
|
|
? formatDuration(tracks.reduce((sum, t) => sum + (t.duration || 0), 0))
|
|
: null;
|
|
const parts = [];
|
|
if (trackCount) parts.push(`${trackCount} track${trackCount > 1 ? "s" : ""}`);
|
|
if (totalDuration) parts.push(totalDuration);
|
|
if (year) parts.push(year);
|
|
headerMetaEl.innerHTML = parts.length
|
|
? `<span class="detail-meta-text">${escHtml(parts.join(" \u00b7 "))}</span>${qualityPill(albumQuality)}`
|
|
: qualityPill(albumQuality);
|
|
// Hide the type-row quality span to avoid duplication when meta row is shown
|
|
if (parts.length && headerQualityEl) headerQualityEl.innerHTML = "";
|
|
}
|
|
|
|
body.innerHTML = `<div class="detail-tracklist">${tracks.map((t, i) => {
|
|
const inQ = downloadQueue.some((q) => q.type === "track" && String(q.id) === String(t.id));
|
|
const coverArt = coverUrl(albumMeta.cover);
|
|
const displayTitle = formatTrackTitle(t);
|
|
// Use the highest of the track's own reported quality and the album quality,
|
|
// since all tracks in a Hi-Res album should be Hi-Res even when the items
|
|
// endpoint reports a lower tier.
|
|
const tq = effectiveQuality(t);
|
|
const trackQuality = QUALITY_ORDER.find((q) => q === tq || q === albumQuality) || null;
|
|
return `<div class="detail-track-item${inQ ? " selected" : ""}" data-id="${t.id}"
|
|
data-title="${escHtml(displayTitle)}" data-artist="${escHtml(t.artist?.name || "")}"
|
|
data-cover="${escHtml(coverArt)}" data-quality="${escHtml(trackQuality || '')}"
|
|
tabindex="0" role="button">
|
|
<span class="detail-track-num">${i + 1}</span>
|
|
<div class="detail-track-info">
|
|
<span class="detail-track-title">${escHtml(displayTitle)}</span>
|
|
<span class="detail-track-artist">${escHtml(t.artist?.name || "")}</span>
|
|
</div>
|
|
${qualityPill(trackQuality)}
|
|
<button class="detail-track-add" title="Add to queue">
|
|
${inQ ? "\u2713 Added" : "+ Add"}
|
|
</button>
|
|
</div>`;
|
|
}).join("")}</div>`;
|
|
|
|
body.querySelectorAll(".detail-track-item").forEach((row) => {
|
|
const onClick = () => {
|
|
const trackId = row.dataset.id;
|
|
const existing = downloadQueue.findIndex((q) => q.type === "track" && String(q.id) === String(trackId));
|
|
const btn = row.querySelector(".detail-track-add");
|
|
if (existing !== -1) {
|
|
removeFromQueue(existing);
|
|
row.classList.remove("selected");
|
|
if (btn) btn.textContent = "+ Add";
|
|
} else {
|
|
const coverArt = coverUrl(albumMeta.cover);
|
|
addToQueue({ type: "track", id: trackId, title: row.dataset.title,
|
|
sub: row.dataset.artist, cover: coverArt, coverRound: false,
|
|
maxQuality: row.dataset.quality || null });
|
|
row.classList.add("selected");
|
|
if (btn) btn.textContent = "\u2713 Added";
|
|
appendLog(`Added track/${trackId} to queue.`, "info");
|
|
}
|
|
};
|
|
row.addEventListener("click", onClick);
|
|
row.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } });
|
|
});
|
|
}
|
|
|
|
async function renderPlaylistDetail(playlistId, body) {
|
|
const [plMeta, allItems] = await Promise.all([
|
|
getPlaylist(playlistId),
|
|
getAllPlaylistItems(playlistId),
|
|
]);
|
|
const tracks = (allItems || []).filter((i) => i.type === "track").map((i) => i.item);
|
|
|
|
// Update the detail header quality pill with the playlist's highest available track quality
|
|
const maxPlaylistQuality = QUALITY_ORDER.find((q) => tracks.some((t) => effectiveQuality(t) === q)) || null;
|
|
const headerQualityEl = $("detail-header-quality");
|
|
if (headerQualityEl) headerQualityEl.innerHTML = qualityPill(maxPlaylistQuality);
|
|
|
|
// Populate the metadata row: track count and max quality
|
|
const headerMetaEl = $("detail-header-meta");
|
|
if (headerMetaEl) {
|
|
const trackCount = tracks.length;
|
|
const totalDuration = tracks.reduce((sum, t) => sum + (t.duration || 0), 0);
|
|
const parts = [];
|
|
if (trackCount) parts.push(`${trackCount} track${trackCount > 1 ? "s" : ""}`);
|
|
if (totalDuration > 0) parts.push(formatDuration(totalDuration));
|
|
headerMetaEl.innerHTML = parts.length
|
|
? `<span class="detail-meta-text">${escHtml(parts.join(" \u00b7 "))}</span>${qualityPill(maxPlaylistQuality)}`
|
|
: qualityPill(maxPlaylistQuality);
|
|
if (parts.length && headerQualityEl) headerQualityEl.innerHTML = "";
|
|
}
|
|
|
|
body.innerHTML = `<div class="detail-tracklist">${tracks.map((t, i) => {
|
|
const inQ = downloadQueue.some((q) => q.type === "track" && String(q.id) === String(t.id));
|
|
const coverArt = coverUrl(t.album?.cover);
|
|
const displayTitle = formatTrackTitle(t);
|
|
const tq = effectiveQuality(t);
|
|
return `<div class="detail-track-item${inQ ? " selected" : ""}" data-id="${t.id}"
|
|
data-title="${escHtml(displayTitle)}" data-artist="${escHtml(t.artist?.name || "")}"
|
|
data-cover="${escHtml(coverArt)}" data-quality="${escHtml(tq || '')}"
|
|
tabindex="0" role="button">
|
|
<span class="detail-track-num">${i + 1}</span>
|
|
<div class="detail-track-info">
|
|
<span class="detail-track-title">${escHtml(displayTitle)}</span>
|
|
<span class="detail-track-artist">${escHtml(t.artist?.name || "")}</span>
|
|
</div>
|
|
${qualityPill(tq)}
|
|
<button class="detail-track-add" title="Add to queue">${inQ ? "\u2713 Added" : "+ Add"}</button>
|
|
</div>`;
|
|
}).join("")}</div>`;
|
|
|
|
body.querySelectorAll(".detail-track-item").forEach((row) => {
|
|
const onClick = () => {
|
|
const trackId = row.dataset.id;
|
|
const existing = downloadQueue.findIndex((q) => q.type === "track" && String(q.id) === String(trackId));
|
|
const btn = row.querySelector(".detail-track-add");
|
|
if (existing !== -1) {
|
|
removeFromQueue(existing);
|
|
row.classList.remove("selected");
|
|
if (btn) btn.textContent = "+ Add";
|
|
} else {
|
|
addToQueue({ type: "track", id: trackId, title: row.dataset.title,
|
|
sub: row.dataset.artist, cover: row.dataset.cover, coverRound: false,
|
|
maxQuality: row.dataset.quality || null });
|
|
row.classList.add("selected");
|
|
if (btn) btn.textContent = "\u2713 Added";
|
|
appendLog(`Added track/${trackId} to queue.`, "info");
|
|
}
|
|
};
|
|
row.addEventListener("click", onClick);
|
|
row.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } });
|
|
});
|
|
}
|
|
|
|
// ─── Library panel ───────────────────────────────────────────────────────────
|
|
|
|
let _librarySection = "tracks";
|
|
let _libraryLoaded = { tracks: false, albums: false, playlists: false };
|
|
let _libraryData = { tracks: [], albums: [], playlists: [] };
|
|
let _libraryFilter = "";
|
|
let _librarySort = "dateAdded";
|
|
let _librarySortDir = "desc";
|
|
|
|
async function loadLibraryIfNeeded() {
|
|
if (!isLoggedIn()) {
|
|
$("library-content").innerHTML = '<p class="no-results">Sign in to see your library.</p>';
|
|
return;
|
|
}
|
|
|
|
// Show welcome message if just logged in
|
|
if (sessionStorage.getItem("tiddl_session_welcome")) {
|
|
sessionStorage.removeItem("tiddl_session_welcome");
|
|
const auth = loadAuth();
|
|
const welcome = $("library-welcome");
|
|
const name = $("library-welcome-name");
|
|
if (welcome) {
|
|
if (name) name.textContent = `Welcome, ${auth?.username || "friend"}!`;
|
|
show(welcome);
|
|
setTimeout(() => hide(welcome), 5000);
|
|
}
|
|
}
|
|
|
|
if (!_libraryLoaded[_librarySection]) {
|
|
renderLibraryContent(null);
|
|
try {
|
|
if (_librarySection === "tracks") {
|
|
const items = await getAllUserFavoriteTracks();
|
|
_libraryData.tracks = items.map((i) => ({ ...(i.item || i), _dateAdded: i.created || i.dateAdded || "" }));
|
|
} else if (_librarySection === "albums") {
|
|
const items = await getAllUserFavoriteAlbums();
|
|
_libraryData.albums = items.map((i) => ({ ...(i.item || i), _dateAdded: i.created || i.dateAdded || "" }));
|
|
} else if (_librarySection === "playlists") {
|
|
const [favItems, ownItems] = await Promise.all([
|
|
getAllUserFavoritePlaylists().catch(() => []),
|
|
getAllUserPlaylists().catch(() => []),
|
|
]);
|
|
const favMapped = favItems.map((i) => ({ ...(i.item || i), _dateAdded: i.created || i.dateAdded || "" }));
|
|
const ownMapped = ownItems.map((i) => ({ ...(i.item || i), _dateAdded: i.created || i.dateAdded || "" }));
|
|
// Deduplicate by uuid
|
|
const seen = new Set();
|
|
_libraryData.playlists = [...ownMapped, ...favMapped].filter((p) => {
|
|
const key = p.uuid || p.id;
|
|
if (seen.has(key)) return false;
|
|
seen.add(key); return true;
|
|
});
|
|
}
|
|
_libraryLoaded[_librarySection] = true;
|
|
renderLibraryContent(_libraryData[_librarySection]);
|
|
} catch (err) {
|
|
const content = $("library-content");
|
|
if (content) content.innerHTML = `<p class="error">Failed to load library: ${escHtml(err.message)}</p>`;
|
|
}
|
|
} else {
|
|
renderLibraryContent(_libraryData[_librarySection]);
|
|
}
|
|
}
|
|
|
|
function renderLibraryContent(items) {
|
|
const content = $("library-content");
|
|
if (!content) return;
|
|
|
|
if (items === null) {
|
|
content.innerHTML = '<p class="searching"><span class="spinner"></span> Loading\u2026</p>';
|
|
return;
|
|
}
|
|
if (!items.length) {
|
|
content.innerHTML = '<p class="no-results">Nothing here yet.</p>';
|
|
return;
|
|
}
|
|
|
|
// Apply sort
|
|
const sorted = [...items].sort((a, b) => {
|
|
let cmp = 0;
|
|
if (_librarySort === "title") {
|
|
cmp = (a.title || a.name || "").localeCompare(b.title || b.name || "");
|
|
} else if (_librarySort === "artist") {
|
|
cmp = (a.artist?.name || a.creator?.name || "").localeCompare(b.artist?.name || b.creator?.name || "");
|
|
} else {
|
|
// dateAdded — compare a to b (asc = oldest first, desc = newest first)
|
|
cmp = (a._dateAdded || "").localeCompare(b._dateAdded || "");
|
|
}
|
|
return _librarySortDir === "asc" ? cmp : -cmp;
|
|
});
|
|
|
|
// Apply filter
|
|
const filterTerm = _libraryFilter.toLowerCase().trim();
|
|
const filtered = filterTerm
|
|
? sorted.filter((item) => {
|
|
const title = (item.title || item.name || "").toLowerCase();
|
|
const artist = (item.artist?.name || item.creator?.name || "").toLowerCase();
|
|
return title.includes(filterTerm) || artist.includes(filterTerm);
|
|
})
|
|
: sorted;
|
|
|
|
if (!filtered.length) {
|
|
content.innerHTML = `<p class="no-results">No results for “${escHtml(_libraryFilter)}”.</p>`;
|
|
return;
|
|
}
|
|
|
|
if (_librarySection === "tracks") {
|
|
content.innerHTML = `<div class="library-track-list">
|
|
<div class="library-track-header" aria-hidden="true">
|
|
<div class="library-track-thumb"></div>
|
|
<div class="library-track-info library-track-header-labels">
|
|
<span>Title / Artist</span>
|
|
</div>
|
|
${_librarySort === "dateAdded" ? '<span class="library-track-date library-track-date-header">Added</span>' : ""}
|
|
<span class="library-track-add-placeholder"></span>
|
|
</div>
|
|
${filtered.map((t) => {
|
|
const inQ = downloadQueue.some((q) => q.type === "track" && String(q.id) === String(t.id));
|
|
const cover = coverUrl(t.album?.cover, 80);
|
|
const dateLabel = _librarySort === "dateAdded" && t._dateAdded
|
|
? (t._dateAdded.length >= 10 ? t._dateAdded.slice(0, 10) : t._dateAdded)
|
|
: "";
|
|
const displayTitle = formatTrackTitle(t);
|
|
const tq = effectiveQuality(t);
|
|
return `<div class="library-track-item${inQ ? " selected" : ""}" data-id="${t.id}"
|
|
data-title="${escHtml(displayTitle)}" data-artist="${escHtml(t.artist?.name || "")}"
|
|
data-cover="${escHtml(coverUrl(t.album?.cover))}" data-quality="${escHtml(tq || '')}"
|
|
tabindex="0" role="button">
|
|
${cover ? `<img src="${escHtml(cover)}" class="library-track-thumb" alt="" loading="lazy"/>` : `<div class="library-track-thumb"></div>`}
|
|
<div class="library-track-info">
|
|
<span class="library-track-title">${escHtml(displayTitle)}</span>
|
|
<span class="library-track-artist">${escHtml(t.artist?.name || "")}</span>
|
|
</div>
|
|
${qualityPill(tq)}
|
|
${dateLabel ? `<span class="library-track-date">${escHtml(dateLabel)}</span>` : ""}
|
|
<button class="library-track-add" title="Add to queue">${inQ ? "\u2713 Added" : "+ Add"}</button>
|
|
</div>`;
|
|
}).join("")}</div>`;
|
|
|
|
content.querySelectorAll(".library-track-item").forEach((row) => {
|
|
const onClick = () => {
|
|
const trackId = row.dataset.id;
|
|
const existing = downloadQueue.findIndex((q) => q.type === "track" && String(q.id) === String(trackId));
|
|
const btn = row.querySelector(".library-track-add");
|
|
if (existing !== -1) {
|
|
removeFromQueue(existing);
|
|
row.classList.remove("selected");
|
|
if (btn) btn.textContent = "+ Add";
|
|
} else {
|
|
addToQueue({ type: "track", id: trackId, title: row.dataset.title,
|
|
sub: row.dataset.artist, cover: row.dataset.cover, coverRound: false,
|
|
maxQuality: row.dataset.quality || null });
|
|
row.classList.add("selected");
|
|
if (btn) btn.textContent = "\u2713 Added";
|
|
appendLog(`Added track/${trackId} to queue.`, "info");
|
|
}
|
|
};
|
|
row.addEventListener("click", onClick);
|
|
row.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(); } });
|
|
});
|
|
|
|
} else {
|
|
// Albums / playlists: card grid
|
|
const type = _librarySection === "albums" ? "album" : "playlist";
|
|
let html = `<div class="result-grid">`;
|
|
for (const item of filtered) {
|
|
const id = type === "album" ? item.id : (item.uuid || item.id);
|
|
const title = item.title || item.name || "";
|
|
const sub = type === "album" ? (item.artist?.name || "") : (item.creator?.name || "Tidal");
|
|
const cover = coverUrl(type === "album" ? item.cover : (item.image || item.squareImage));
|
|
const quality = type === "album" ? effectiveQuality(item) : null;
|
|
html += buildResultCard(type, id, title, sub, cover, false, quality);
|
|
}
|
|
html += `</div>`;
|
|
content.innerHTML = html;
|
|
attachLibraryCardHandlers(content);
|
|
}
|
|
}
|
|
|
|
function attachLibraryCardHandlers(container) {
|
|
container.querySelectorAll(".result-card").forEach((card) => {
|
|
const isTrack = card.dataset.type === "track";
|
|
if (isTrack) {
|
|
card.addEventListener("click", () => toggleResultInQueue(card));
|
|
} else {
|
|
card.addEventListener("click", (e) => {
|
|
if (e.target.closest(".card-queue-btn")) return;
|
|
navigateToDetail(card.dataset.type, card.dataset.id,
|
|
card.dataset.title, card.dataset.sub, card.dataset.cover, "library",
|
|
card.dataset.quality || null);
|
|
});
|
|
const qaBtn = card.querySelector(".card-queue-btn");
|
|
if (qaBtn) qaBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleResultInQueue(card); });
|
|
}
|
|
});
|
|
}
|
|
|
|
// ─── Settings ─────────────────────────────────────────────────────────────────
|
|
|
|
function handleSaveSettings() {
|
|
saveSettingsForm(appendLog);
|
|
// Re-render queue in case advanced mode changed
|
|
renderQueue();
|
|
// Sync quality picker with saved setting (without re-attaching event listeners)
|
|
syncQualityPicker();
|
|
// Sync all-qualities mode UI (banner, disabled state of picker)
|
|
updateAllQualitiesUI();
|
|
const msg = $("settings-save-msg");
|
|
if (msg) {
|
|
msg.classList.add("visible");
|
|
setTimeout(() => msg.classList.remove("visible"), 2500);
|
|
}
|
|
}
|
|
|
|
// ─── Theme icon ───────────────────────────────────────────────────────────────
|
|
|
|
const THEME_ICONS = {
|
|
dark: `<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>`,
|
|
light: `<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>`,
|
|
system: `<svg width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>`,
|
|
};
|
|
function updateThemeIcon() {
|
|
const icon = $("theme-icon");
|
|
if (!icon) return;
|
|
const t = document.documentElement.getAttribute("data-theme") || "system";
|
|
icon.outerHTML = (THEME_ICONS[t] || THEME_ICONS.system).replace("svg ", `svg id="theme-icon" `);
|
|
}
|
|
|
|
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
|
|
|
// ─── Tooltips ─────────────────────────────────────────────────────────────────
|
|
|
|
function initTooltips() {
|
|
const box = document.createElement("div");
|
|
box.className = "tip-box";
|
|
box.setAttribute("role", "tooltip");
|
|
document.body.appendChild(box);
|
|
|
|
function positionAndShow(el) {
|
|
box.textContent = el.dataset.tip;
|
|
// Measure while hidden (visibility:hidden keeps layout but hides paint)
|
|
box.style.visibility = "hidden";
|
|
box.classList.add("visible");
|
|
const bw = box.offsetWidth;
|
|
const bh = box.offsetHeight;
|
|
box.classList.remove("visible");
|
|
box.style.visibility = "";
|
|
|
|
const r = el.getBoundingClientRect();
|
|
const gap = 8;
|
|
// Prefer above the element; flip below if not enough room
|
|
let top = r.top - bh - gap;
|
|
if (top < gap) top = r.bottom + gap;
|
|
const left = Math.max(gap, Math.min(r.left, window.innerWidth - bw - gap));
|
|
box.style.top = `${top}px`;
|
|
box.style.left = `${left}px`;
|
|
box.classList.add("visible");
|
|
}
|
|
|
|
function hide() { box.classList.remove("visible"); }
|
|
|
|
// Mouse: show on hover of any [data-tip] element
|
|
document.addEventListener("mouseover", (e) => {
|
|
const el = e.target.closest("[data-tip]");
|
|
if (el) positionAndShow(el);
|
|
});
|
|
document.addEventListener("mouseout", (e) => {
|
|
if (!e.relatedTarget?.closest("[data-tip]")) hide();
|
|
});
|
|
|
|
// Keyboard: show on focus, hide on blur/Escape
|
|
document.addEventListener("focusin", (e) => {
|
|
const el = e.target.closest("[data-tip]");
|
|
if (el) positionAndShow(el);
|
|
});
|
|
document.addEventListener("focusout", (e) => {
|
|
if (!e.relatedTarget?.closest("[data-tip]")) hide();
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") hide();
|
|
});
|
|
|
|
// Prevent `.tip` spans inside <label> elements from toggling their checkbox.
|
|
// The capture phase intercepts the click before it bubbles to the label.
|
|
document.addEventListener("click", (e) => {
|
|
if (e.target.closest(".tip")) { e.preventDefault(); e.stopPropagation(); }
|
|
}, true);
|
|
}
|
|
|
|
export function init() {
|
|
initTooltips();
|
|
|
|
// Apply stored appearance
|
|
const theme = getTheme();
|
|
applyTheme(theme);
|
|
applyAccentColor(getAccentColor());
|
|
updateThemeIcon();
|
|
initBrowserChrome();
|
|
|
|
// Nav tabs
|
|
document.querySelectorAll(".tab-btn").forEach((btn) =>
|
|
btn.addEventListener("click", () => {
|
|
const tab = btn.dataset.tab;
|
|
activateTab(tab);
|
|
if (tab === "library") loadLibraryIfNeeded();
|
|
if (tab === "search" && !lastSearchData && isLoggedIn()) showSearchSuggestions();
|
|
})
|
|
);
|
|
|
|
// Detail back button
|
|
$("btn-detail-back")?.addEventListener("click", backFromDetail);
|
|
|
|
// Auth
|
|
$("btn-login")?.addEventListener("click", startLogin);
|
|
$("btn-logout")?.addEventListener("click", handleLogout);
|
|
|
|
// Download status panel tab switching
|
|
$("dl-tab-progress-btn")?.addEventListener("click", () => switchDlTab("progress"));
|
|
$("dl-tab-log-btn")?.addEventListener("click", () => switchDlTab("log"));
|
|
$("btn-clear-log")?.addEventListener("click", clearLog);
|
|
$("btn-export-log")?.addEventListener("click", exportLog);
|
|
|
|
// Toggle collapse/expand of the pinned status bar
|
|
$("btn-toggle-status-bar")?.addEventListener("click", () => {
|
|
const bar = $("dl-status-bar");
|
|
const btn = $("btn-toggle-status-bar");
|
|
if (!bar) return;
|
|
const nowCollapsed = bar.classList.toggle("collapsed");
|
|
if (btn) {
|
|
btn.setAttribute("aria-expanded", String(!nowCollapsed));
|
|
btn.setAttribute("aria-label", nowCollapsed ? "Expand status bar" : "Collapse status bar");
|
|
}
|
|
});
|
|
|
|
// Download
|
|
$("btn-download")?.addEventListener("click", handleDownload);
|
|
$("btn-clear-queue")?.addEventListener("click", clearQueue);
|
|
|
|
// URL pill input + file import
|
|
initUrlPillInput();
|
|
|
|
// Quality picker
|
|
initQualityPicker();
|
|
|
|
// Search
|
|
$("btn-search")?.addEventListener("click", handleSearch);
|
|
const searchInput = $("search-input");
|
|
searchInput?.addEventListener("keydown", (e) => { if (e.key === "Enter") handleSearch(); });
|
|
searchInput?.addEventListener("input", () => {
|
|
updateSearchClearBtn();
|
|
const container = $("search-results");
|
|
if (!searchInput.value && container && lastSearchData) {
|
|
lastSearchData = null;
|
|
container.innerHTML = "";
|
|
if (isLoggedIn()) showSearchSuggestions();
|
|
}
|
|
});
|
|
searchInput?.addEventListener("focus", () => showSearchHistory());
|
|
searchInput?.addEventListener("blur", () => setTimeout(() => hide($("search-history")), 150));
|
|
|
|
$("btn-clear-search")?.addEventListener("click", () => {
|
|
if (searchInput) searchInput.value = "";
|
|
updateSearchClearBtn();
|
|
const container = $("search-results");
|
|
if (container) container.innerHTML = "";
|
|
lastSearchData = null;
|
|
hide($("search-history"));
|
|
if (isLoggedIn()) showSearchSuggestions();
|
|
});
|
|
$("btn-clear-history")?.addEventListener("click", () => {
|
|
clearSearchHistory();
|
|
hide($("search-history"));
|
|
});
|
|
$("btn-go-downloads")?.addEventListener("click", () => activateTab("download"));
|
|
|
|
// Search type pills — also trigger search if query is present but no results yet
|
|
document.querySelectorAll(".type-pill").forEach((pill) => {
|
|
if (!pill.closest("#search-type-pills")) return;
|
|
pill.addEventListener("click", () => {
|
|
activeTypeFilter = pill.dataset.type;
|
|
document.querySelectorAll("#search-type-pills .type-pill").forEach((p) =>
|
|
p.classList.toggle("active", p.dataset.type === activeTypeFilter)
|
|
);
|
|
if (lastSearchData) {
|
|
renderSearchResults(lastSearchData, activeTypeFilter);
|
|
} else if ($("search-input")?.value?.trim()) {
|
|
handleSearch();
|
|
}
|
|
});
|
|
});
|
|
|
|
// Library section pills
|
|
document.querySelectorAll("#library-section-pills .type-pill").forEach((pill) => {
|
|
pill.addEventListener("click", () => {
|
|
_librarySection = pill.dataset.section;
|
|
_libraryFilter = "";
|
|
const filterInput = $("library-filter-input");
|
|
if (filterInput) { filterInput.value = ""; }
|
|
hide($("btn-clear-library-filter"));
|
|
document.querySelectorAll("#library-section-pills .type-pill").forEach((p) =>
|
|
p.classList.toggle("active", p.dataset.section === _librarySection)
|
|
);
|
|
loadLibraryIfNeeded();
|
|
});
|
|
});
|
|
|
|
// Library sort select
|
|
$("library-sort-select")?.addEventListener("change", (e) => {
|
|
const [field, dir] = e.target.value.split("-");
|
|
_librarySort = field;
|
|
_librarySortDir = dir || "asc";
|
|
if (_libraryLoaded[_librarySection]) {
|
|
renderLibraryContent(_libraryData[_librarySection]);
|
|
}
|
|
});
|
|
|
|
// Library filter input
|
|
const libraryFilterInput = $("library-filter-input");
|
|
const btnClearLibFilter = $("btn-clear-library-filter");
|
|
libraryFilterInput?.addEventListener("input", () => {
|
|
_libraryFilter = libraryFilterInput.value;
|
|
btnClearLibFilter?.classList.toggle("hidden", !libraryFilterInput.value);
|
|
if (_libraryLoaded[_librarySection]) {
|
|
renderLibraryContent(_libraryData[_librarySection]);
|
|
}
|
|
});
|
|
btnClearLibFilter?.addEventListener("click", () => {
|
|
libraryFilterInput.value = "";
|
|
_libraryFilter = "";
|
|
hide(btnClearLibFilter);
|
|
if (_libraryLoaded[_librarySection]) {
|
|
renderLibraryContent(_libraryData[_librarySection]);
|
|
}
|
|
});
|
|
|
|
// Account page logged-in buttons
|
|
$("btn-go-library-from-auth")?.addEventListener("click", () => {
|
|
activateTab("library");
|
|
loadLibraryIfNeeded();
|
|
});
|
|
$("btn-go-download-from-auth")?.addEventListener("click", () => activateTab("download"));
|
|
$("btn-logout-account")?.addEventListener("click", handleLogout);
|
|
|
|
// Help page side nav
|
|
document.querySelectorAll(".help-nav-btn").forEach((btn) => {
|
|
btn.addEventListener("click", () => {
|
|
const sectionId = btn.dataset.section;
|
|
document.querySelectorAll(".help-nav-btn").forEach((b) =>
|
|
b.classList.toggle("active", b.dataset.section === sectionId)
|
|
);
|
|
document.querySelectorAll(".help-section").forEach((s) =>
|
|
s.classList.toggle("active", s.id === `help-${sectionId}`)
|
|
);
|
|
});
|
|
});
|
|
|
|
// Settings
|
|
$("btn-save-settings")?.addEventListener("click", handleSaveSettings);
|
|
|
|
// Theme toggle
|
|
$("theme-toggle")?.addEventListener("click", () => { cycleTheme(); updateThemeIcon(); });
|
|
|
|
initThemeUI();
|
|
initAccentColorUI(appendLog);
|
|
initUiEffectsUI();
|
|
initTemplateBuilders();
|
|
initExperimentalSettingUI();
|
|
|
|
// Initial state
|
|
updateAuthBadge();
|
|
loadSettingsForm();
|
|
updateAllQualitiesUI();
|
|
|
|
if (!isLoggedIn()) {
|
|
activateTab("auth");
|
|
} else {
|
|
activateTab("download");
|
|
// Preload search suggestions in background
|
|
showSearchSuggestions();
|
|
}
|
|
}
|