kv-music/js/tracker.js

526 lines
21 KiB
JavaScript

//js/tracker.js
import { escapeHtml, SVG_DOWNLOAD } from './utils.js';
let artistsData = [];
let globalPlayer = null;
let globalUi = null;
async function loadArtistsData() {
try {
const response = await fetch('/artists.ndjson');
if (!response.ok) throw new Error('Network response was not ok');
const text = await response.text();
artistsData = text
.trim()
.split('\n')
.filter((line) => line.trim())
.map((line) => {
try {
return JSON.parse(line);
} catch (e) {
return null;
}
})
.filter((item) => item !== null);
} catch (e) {
console.error('Failed to load Artists LIst:', e);
}
}
function getSheetId(url) {
if (!url) return null;
const match = url.match(/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
return match ? match[1] : null;
}
async function fetchTrackerData(sheetId) {
try {
const response = await fetch(
`https://corsproxy.io/?${encodeURIComponent(`https://tracker.israeli.ovh/get/${sheetId}`)}`
);
if (!response.ok) return null;
return await response.json();
} catch (e) {
console.error('Failed to fetch tracker data', e);
return null;
}
}
function parseDuration(durationStr) {
if (!durationStr || durationStr === 'N/A') return 0;
const parts = durationStr.split(':');
if (parts.length === 2) {
return parseInt(parts[0]) * 60 + parseInt(parts[1]);
}
return 0;
}
function getDirectUrl(rawUrl) {
if (!rawUrl) return null;
if (rawUrl.includes('pillows.su/f/')) {
const match = rawUrl.match(/pillows\.su\/f\/([a-f0-9]+)/);
if (match) return `https://api.pillows.su/api/download/${match[1]}`;
} else if (rawUrl.includes('music.froste.lol/song/')) {
const match = rawUrl.match(/music\.froste\.lol\/song\/([a-f0-9]+)/);
if (match) return `https://music.froste.lol/song/${match[1]}/download`;
}
return rawUrl;
}
function renderLoadButton(container, sheetId, artistName) {
container.innerHTML = '';
container.style.display = 'block';
const wrapper = document.createElement('div');
wrapper.style.textAlign = 'center';
wrapper.style.padding = '2rem';
const button = document.createElement('button');
button.className = 'btn-primary';
button.textContent = 'Load Unreleased Projects';
button.style.fontSize = '1.1rem';
button.style.padding = '1rem 2rem';
button.onclick = async () => {
button.textContent = 'Loading...';
button.disabled = true;
const trackerData = await fetchTrackerData(sheetId);
if (trackerData) {
renderTracker(trackerData, container, artistName);
} else {
button.textContent = 'Failed to load';
setTimeout(() => {
button.disabled = false;
button.textContent = 'Load Unreleased Projects';
}, 2000);
}
};
wrapper.appendChild(button);
container.appendChild(wrapper);
}
function renderTracker(trackerData, container, artistName) {
container.innerHTML = `
<h2 class="section-title" style="margin-bottom: 0.5rem;">Unreleased Projects</h2>
<p style="color: var(--muted-foreground); margin-bottom: 1.5rem; font-size: 0.9rem;">
Unreleased Songs & Info Provided By <a href="https://artistgrid.cx" target="_blank" style="text-decoration: underline;">ArtistGrid</a>. Consider Donating to Them.
</p>
`;
const erasContainer = document.createElement('div');
erasContainer.className = 'card-grid';
erasContainer.style.opacity = '0';
erasContainer.style.transform = 'translateY(20px)';
erasContainer.style.transition = 'opacity 0.5s ease, transform 0.5s ease';
container.appendChild(erasContainer);
if (!trackerData.eras) return;
Object.values(trackerData.eras).forEach((era) => {
const card = document.createElement('div');
card.className = 'card';
card.style.cursor = 'pointer';
const imgWrapper = document.createElement('div');
imgWrapper.className = 'card-image-wrapper';
const img = document.createElement('img');
img.className = 'card-image';
img.src = era.image ? `https://corsproxy.io/?${encodeURIComponent(era.image)}` : 'assets/logo.svg';
img.alt = era.name;
img.loading = 'lazy';
imgWrapper.appendChild(img);
const title = document.createElement('div');
title.className = 'card-title';
title.textContent = era.name;
const subtitle = document.createElement('div');
subtitle.className = 'card-subtitle';
subtitle.textContent = era.timeline || 'Unreleased';
card.appendChild(imgWrapper);
card.appendChild(title);
card.appendChild(subtitle);
card.onclick = () => showEraSongs(era, artistName);
erasContainer.appendChild(card);
});
requestAnimationFrame(() => {
erasContainer.style.opacity = '1';
erasContainer.style.transform = 'translateY(0)';
});
}
function showEraSongs(era, artistName) {
const modal = document.getElementById('tracker-modal');
const overlay = modal.querySelector('.modal-overlay');
const closeBtn = document.getElementById('close-tracker-modal');
const img = document.getElementById('tracker-header-image');
const title = document.getElementById('tracker-header-title');
const meta = document.getElementById('tracker-header-meta');
img.src = era.image ? `https://corsproxy.io/?${encodeURIComponent(era.image)}` : 'assets/logo.svg';
img.alt = era.name;
title.textContent = era.name;
meta.textContent = `${artistName}${era.timeline || 'Unreleased'}`;
const trackList = document.getElementById('tracker-tracklist');
const filterContainer = document.getElementById('tracker-filters');
filterContainer.innerHTML = '';
while (trackList.lastElementChild && !trackList.lastElementChild.classList.contains('track-list-header')) {
trackList.removeChild(trackList.lastElementChild);
}
const filters = [
{ label: 'All', emoji: '' },
{ label: 'Best Of', emoji: '⭐' },
{ label: 'Special', emoji: '✨' },
{ label: 'Grails', emoji: '🏆' },
{ label: 'Wanted', emoji: '🥇' },
{ label: 'Worst Of', emoji: '🗑️' },
];
let activeFilter = '';
const applyFilter = () => {
const items = trackList.querySelectorAll('.track-item');
items.forEach((item) => {
const titleEl = item.querySelector('.title');
if (titleEl) {
const title = titleEl.textContent.trim();
if (activeFilter && !title.startsWith(activeFilter)) {
item.style.display = 'none';
} else {
item.style.display = '';
}
}
});
const categories = trackList.querySelectorAll('h4');
categories.forEach((cat) => {
let next = cat.nextElementSibling;
let hasVisibleItems = false;
while (next && next.tagName !== 'H4') {
if (next.classList.contains('track-item') && next.style.display !== 'none') {
hasVisibleItems = true;
break;
}
next = next.nextElementSibling;
}
cat.style.display = hasVisibleItems ? 'block' : 'none';
});
};
filters.forEach((filter) => {
const btn = document.createElement('button');
btn.className = 'btn-secondary';
btn.textContent = filter.emoji ? `${filter.emoji} ${filter.label}` : filter.label;
btn.style.fontSize = '0.85rem';
btn.style.padding = '0.4rem 0.8rem';
btn.style.borderRadius = '2rem';
if (filter.emoji === '') {
btn.style.backgroundColor = 'var(--primary)';
btn.style.color = 'var(--primary-foreground)';
}
btn.onclick = () => {
Array.from(filterContainer.children).forEach((b) => {
b.style.backgroundColor = '';
b.style.color = '';
});
btn.style.backgroundColor = 'var(--primary)';
btn.style.color = 'var(--primary-foreground)';
activeFilter = filter.emoji;
applyFilter();
};
filterContainer.appendChild(btn);
});
let globalIndex = 1;
if (era.data) {
Object.entries(era.data).forEach(([category, songs]) => {
if (!songs || songs.length === 0) return;
const catTitle = document.createElement('h4');
catTitle.textContent = category;
catTitle.style.padding = '1rem 0.5rem 0.5rem';
catTitle.style.color = 'var(--highlight)';
catTitle.style.fontWeight = '600';
catTitle.style.borderBottom = '1px solid var(--border)';
catTitle.style.marginBottom = '0.5rem';
trackList.appendChild(catTitle);
const isValidUrl = (u) => u && typeof u === 'string' && u.trim().length > 0;
songs.forEach((song) => {
const trackItem = document.createElement('div');
trackItem.className = 'track-item';
trackItem.innerHTML = `
<div class="track-number">${globalIndex++}</div>
<div class="track-item-info">
<div class="track-item-details">
<div class="title">${escapeHtml(song.name)}</div>
<div class="artist">${escapeHtml(song.extra || artistName || document.getElementById('artist-detail-name')?.textContent || 'Unknown Artist')}</div>
</div>
</div>
<div class="track-item-duration">${song.track_length || '--:--'}</div>
<div class="track-item-actions">
<button class="track-action-btn" title="Download">
${SVG_DOWNLOAD}
</button>
</div>
`;
const hasValidUrl = isValidUrl(song.url) || (song.urls && song.urls.some(isValidUrl));
trackItem.oncontextmenu = (e) => {
if (!hasValidUrl) return;
e.preventDefault();
const contextMenu = document.getElementById('context-menu');
if (contextMenu) {
const rawUrl =
(isValidUrl(song.url) ? song.url : null) || (song.urls ? song.urls.find(isValidUrl) : null);
const directUrl = getDirectUrl(rawUrl);
const track = {
id: `tracker-${song.name}`,
title: song.name,
artist: {
name:
artistName ||
document.getElementById('artist-detail-name')?.textContent ||
'Unknown Artist',
},
artists: [
{
name:
artistName ||
document.getElementById('artist-detail-name')?.textContent ||
'Unknown Artist',
},
],
album: {
title: era.name,
cover: era.image,
},
duration: parseDuration(song.track_length),
isTracker: true,
audioUrl: directUrl,
remoteUrl: directUrl,
};
contextMenu._contextTrack = track;
['go-to-album', 'go-to-artist', 'toggle-like', 'download', 'track-mix'].forEach((action) => {
const item = contextMenu.querySelector(`[data-action="${action}"]`);
if (item) item.style.display = 'none';
});
let left = e.pageX;
let top = e.pageY;
if (left + 160 > window.innerWidth) left = window.innerWidth - 170;
if (top + 200 > window.innerHeight) top = e.pageY - 200;
contextMenu.style.left = `${left}px`;
contextMenu.style.top = `${top}px`;
contextMenu.style.display = 'block';
}
};
if (hasValidUrl) {
trackItem.onclick = async () => {
if (song.track_length === '-') {
const targetUrl = song.urls && song.urls.length > 0 ? song.urls[0] : song.url;
if (targetUrl) window.open(targetUrl, '_blank');
return;
}
if (trackItem.classList.contains('loading')) return;
document.body.style.cursor = 'wait';
trackItem.classList.add('loading');
const trackNumEl = trackItem.querySelector('.track-number');
const originalNum = trackNumEl.textContent;
trackNumEl.innerHTML =
'<svg class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg>';
let urlsToTry = [];
if (isValidUrl(song.url)) {
urlsToTry.push(song.url);
}
if (song.urls) {
urlsToTry.push(...song.urls.filter(isValidUrl));
}
let audioUrl = null;
let successfulUrl = null;
for (let rawUrl of urlsToTry) {
console.log(`Trying: ${rawUrl}`);
let downloadUrl = rawUrl;
if (rawUrl.includes('pillows.su/f/')) {
const match = rawUrl.match(/pillows\.su\/f\/([a-f0-9]+)/);
if (match) {
downloadUrl = `https://api.pillows.su/api/download/${match[1]}`;
}
} else if (rawUrl.includes('music.froste.lol/song/')) {
const match = rawUrl.match(/music\.froste\.lol\/song\/([a-f0-9]+)/);
if (match) {
downloadUrl = `https://music.froste.lol/song/${match[1]}/download`;
}
}
try {
console.log(`Fetching: ${downloadUrl}`);
const response = await fetch(downloadUrl);
if (response.ok) {
const contentType = response.headers.get('content-type') || '';
if (
contentType.includes('audio/') ||
contentType.includes('mpeg') ||
contentType.includes('octet-stream')
) {
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength > 1000) {
const blob = new Blob([arrayBuffer], { type: 'audio/mpeg' });
audioUrl = URL.createObjectURL(blob);
successfulUrl = downloadUrl;
console.log(`✓ Success (${(arrayBuffer.byteLength / 1024).toFixed(0)}KB)`);
break;
}
}
}
} catch (e) {
console.log(`Download from ${downloadUrl} failed:`, e.message);
}
}
document.body.style.cursor = 'default';
trackItem.classList.remove('loading');
trackNumEl.textContent = originalNum;
if (!audioUrl) {
alert(
`Unable to load this track! :( The source may be unavailable.\n\nTried ${urlsToTry.length} URL(s)`
);
return;
}
if (globalPlayer) {
const track = {
id: `tracker-${song.name}`,
title: song.name,
artist: {
name:
artistName ||
document.getElementById('artist-detail-name')?.textContent ||
'Unknown Artist',
},
artists: [
{
name:
artistName ||
document.getElementById('artist-detail-name')?.textContent ||
'Unknown Artist',
},
],
album: {
title: era.name,
cover: era.image,
},
duration: parseDuration(song.track_length),
isTracker: true,
audioUrl: audioUrl,
remoteUrl:
successfulUrl ||
(urlsToTry.length > 0 ? getDirectUrl(urlsToTry[0]) : null) ||
getDirectUrl(song.url),
};
globalPlayer.setQueue([track], 0);
document.getElementById('shuffle-btn')?.classList.remove('active');
globalPlayer.playTrackFromQueue();
}
};
const downloadBtn = trackItem.querySelector('.track-action-btn');
if (downloadBtn) {
downloadBtn.onclick = (e) => {
e.stopPropagation();
alert('Download not available for tracker songs yet.');
};
}
} else {
trackItem.classList.add('unavailable');
trackItem.style.opacity = '0.5';
trackItem.style.cursor = 'default';
}
trackList.appendChild(trackItem);
});
});
}
modal.classList.add('active');
const closeModal = () => modal.classList.remove('active');
overlay.onclick = closeModal;
closeBtn.onclick = closeModal;
}
export async function initTracker(player, ui) {
globalPlayer = player;
globalUi = ui;
await loadArtistsData();
const checkAndRenderTracker = async () => {
const artistNameEl = document.getElementById('artist-detail-name');
const trackerSection = document.getElementById('artist-tracker-section');
if (artistNameEl && trackerSection && artistNameEl.textContent) {
const artistName = artistNameEl.textContent.trim();
if (trackerSection.dataset.artist === artistName) return;
trackerSection.dataset.artist = artistName;
trackerSection.innerHTML = '';
trackerSection.style.display = 'none';
const artistEntry = artistsData.find((a) => a.name.toLowerCase() === artistName.toLowerCase());
if (artistEntry && artistEntry.url) {
const sheetId = getSheetId(artistEntry.url);
if (sheetId) {
renderLoadButton(trackerSection, sheetId, artistEntry.name);
}
}
}
};
const observer = new MutationObserver(checkAndRenderTracker);
const artistPage = document.getElementById('page-artist');
if (artistPage) {
observer.observe(artistPage, { attributes: true, childList: true, subtree: true });
checkAndRenderTracker();
}
}