nkbmusic/site/app.js
metatroncubeswdev d73fd2de70 nkbmusic.com marketing site with live song samples from the backend
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:28:38 -04:00

162 lines
7.1 KiB
JavaScript

// Nanga Kathai marketing site. Reads a few songs and the catalogue counts from the live Supabase
// backend (same tables as lib/data/catalog_repository.dart; row-level security only returns
// approved songs) and plays them in the browser. Settings come from config.js (tools/build_site.py).
(() => {
'use strict';
const cfg = window.NK_CONFIG || {};
const API = `${cfg.supabaseUrl}/rest/v1`;
const headers = { apikey: cfg.supabaseKey, Authorization: `Bearer ${cfg.supabaseKey}` };
const FEATURED = 6;
// Same occasion labels and colours as lib/core/models.dart.
const OCCASIONS = {
huttu: ['Huttu', '#3B6E8F'],
habba: ['Habba', '#B5541E'],
madhuvae: ['Madhuvae', '#9C2F4A'],
devotional: ['Devotional', '#6B4FA0'],
folk: ['Folk songs', '#2E7D5B'],
saavu: ['Saavu', '#4A4F57'],
instrumental: ['Instrumental', '#8A5A2B'],
};
const $ = (sel, el = document) => el.querySelector(sel);
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const fmt = (sec) => `${Math.floor(sec / 60)}:${String(Math.round(sec % 60)).padStart(2, '0')}`;
const icons = {
play: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg>',
pause: '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>',
};
async function get(path, extra = {}) {
const res = await fetch(`${API}/${path}`, { headers: { ...headers, ...extra } });
if (!res.ok) throw new Error(`${res.status} ${path}`);
return res;
}
// One song from each of the biggest albums, so the sample shows some variety.
async function loadSongs() {
const [artists, albums, tracks] = await Promise.all([
get('artists?select=id,name_latin').then((r) => r.json()),
get('albums?select=id,title_latin,cover_url').then((r) => r.json()),
get('tracks?select=id,title_latin,album_id,occasion,duration_ms,audio_key_128,cover_url,'
+ 'track_artists(artist_id,position,role)&status=eq.approved&order=created_at.asc').then((r) => r.json()),
]);
const artistName = new Map(artists.map((a) => [a.id, a.name_latin]));
const albumById = new Map(albums.map((a) => [a.id, a]));
const byAlbum = new Map();
for (const t of tracks) {
if (!byAlbum.has(t.album_id)) byAlbum.set(t.album_id, []);
byAlbum.get(t.album_id).push(t);
}
const picks = [...byAlbum.values()].sort((a, b) => b.length - a.length).slice(0, FEATURED).map((list) => list[0]);
const songs = picks.map((r) => {
const album = albumById.get(r.album_id);
const singers = (r.track_artists || []).filter((t) => t.role === 'singer')
.sort((a, b) => a.position - b.position).map((t) => artistName.get(t.artist_id)).filter(Boolean);
const [label, color] = OCCASIONS[r.occasion] || OCCASIONS.folk;
return {
id: r.id,
title: r.title_latin,
credit: singers.join(', ') || album?.title_latin || 'Badaga song',
album: album?.title_latin,
label,
color,
coverUrl: r.cover_url || album?.cover_url || null,
audioUrl: `${cfg.mediaBaseUrl}/${r.audio_key_128}`,
seconds: (r.duration_ms || 0) / 1000,
};
});
return { songs, songCount: tracks.length, albumCount: byAlbum.size };
}
const cover = (s) => `<div class="cover" style="background:${s.color}">${s.coverUrl
? `<img src="${esc(s.coverUrl)}" alt="" loading="lazy" onerror="this.remove()">` : '<span>&#9834;</span>'}</div>`;
// ---------- Player ----------
const audio = new Audio();
audio.preload = 'none';
let songs = [];
let current = -1;
function play(i) {
if (i === current) { audio.paused ? audio.play() : audio.pause(); return; }
current = i;
const s = songs[i];
audio.src = s.audioUrl;
audio.play().catch(() => {});
$('#phone-title').textContent = s.title;
$('#phone-credit').textContent = s.credit;
$('#phone-art').innerHTML = cover(s);
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: s.title, artist: s.credit, album: s.album || 'Nanga Kathai',
artwork: [{ src: s.coverUrl || '/img/icon-256.png', sizes: '256x256' }],
});
}
sync();
}
function sync() {
document.querySelectorAll('.sample').forEach((el, i) => {
const on = i === current && !audio.paused;
el.classList.toggle('playing', i === current);
el.querySelector('.sample-btn').innerHTML = on ? icons.pause : icons.play;
el.querySelector('.sample-btn').setAttribute('aria-label', `${on ? 'Pause' : 'Play'} ${songs[i].title}`);
});
const btn = $('#phone-play');
btn.innerHTML = audio.paused ? icons.play : icons.pause;
btn.setAttribute('aria-label', audio.paused ? 'Play' : 'Pause');
}
audio.addEventListener('play', sync);
audio.addEventListener('pause', sync);
audio.addEventListener('ended', () => (current < songs.length - 1 ? play(current + 1) : sync()));
audio.addEventListener('timeupdate', () => {
if (!audio.duration) return;
$('#phone-bar').style.width = `${(audio.currentTime / audio.duration) * 100}%`;
$('#phone-time').textContent = fmt(audio.currentTime);
$('#phone-total').textContent = fmt(audio.duration);
});
$('#phone-play').onclick = () => play(current < 0 ? 0 : current);
$('#phone-next').onclick = () => songs.length && play((current + 1) % songs.length);
$('#phone-prev').onclick = () => songs.length && play(current <= 0 ? 0 : current - 1);
loadSongs().then((data) => {
songs = data.songs;
$('#count-songs').textContent = data.songCount;
$('#count-albums').textContent = data.albumCount;
$('#samples').innerHTML = songs.map((s, i) => `
<li class="sample">
<button class="sample-btn" aria-label="Play ${esc(s.title)}">${icons.play}</button>
${cover(s)}
<div class="t"><b>${esc(s.title)}</b><span>${esc(s.credit)}</span></div>
<span class="tag" style="--c:${s.color}">${esc(s.label)}</span>
<span class="dur">${s.seconds ? fmt(s.seconds) : ''}</span>
</li>`).join('');
$('#samples').querySelectorAll('.sample').forEach((el, i) => { el.onclick = () => play(i); });
if (songs[0]) {
$('#phone-title').textContent = songs[0].title;
$('#phone-credit').textContent = songs[0].credit;
$('#phone-art').innerHTML = cover(songs[0]);
$('#phone-total').textContent = fmt(songs[0].seconds);
}
}).catch((err) => {
console.error(err);
$('#samples').innerHTML = '<li class="empty">The songs could not be loaded right now. Please refresh the page.</li>';
});
// ---------- Store links: "coming soon" until the URLs are set in config.js ----------
document.querySelectorAll('[data-store]').forEach((a) => {
const url = cfg[a.dataset.store];
if (url) {
a.href = url;
a.classList.remove('soon');
a.querySelector('small').textContent = a.dataset.store === 'playUrl' ? 'Get it on' : 'Download on the';
} else {
a.removeAttribute('href');
}
});
$('#year').textContent = new Date().getFullYear();
})();