Merge pull request #216 from blacksigkill/feature/artist-bio
Feature: artist bio
This commit is contained in:
commit
8fa72bfd17
5 changed files with 264 additions and 0 deletions
|
|
@ -2605,6 +2605,7 @@
|
||||||
<div class="detail-header-info">
|
<div class="detail-header-info">
|
||||||
<h1 class="title" id="artist-detail-name"></h1>
|
<h1 class="title" id="artist-detail-name"></h1>
|
||||||
<div class="meta" id="artist-detail-meta"></div>
|
<div class="meta" id="artist-detail-meta"></div>
|
||||||
|
<div id="artist-detail-bio" class="artist-bio"></div>
|
||||||
<div class="detail-header-actions">
|
<div class="detail-header-actions">
|
||||||
<button id="play-artist-radio-btn" class="btn-primary" title="Artist Radio">
|
<button id="play-artist-radio-btn" class="btn-primary" title="Artist Radio">
|
||||||
<svg
|
<svg
|
||||||
|
|
|
||||||
31
js/api.js
31
js/api.js
|
|
@ -12,6 +12,7 @@ import { addMetadataToAudio } from './metadata.js';
|
||||||
import { DashDownloader } from './dash-downloader.js';
|
import { DashDownloader } from './dash-downloader.js';
|
||||||
|
|
||||||
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
export const DASH_MANIFEST_UNAVAILABLE_CODE = 'DASH_MANIFEST_UNAVAILABLE';
|
||||||
|
const TIDAL_V2_TOKEN = 'txNoH4kkV41MfH25';
|
||||||
|
|
||||||
export class LosslessAPI {
|
export class LosslessAPI {
|
||||||
constructor(settings) {
|
constructor(settings) {
|
||||||
|
|
@ -793,6 +794,36 @@ export class LosslessAPI {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtistBiography(artistId) {
|
||||||
|
const cacheKey = `artist_bio_v1_${artistId}`;
|
||||||
|
const cached = await this.cache.get('artist', cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `https://api.tidal.com/v1/artists/${artistId}/bio?locale=en_US&countryCode=GB`;
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
'X-Tidal-Token': TIDAL_V2_TOKEN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.text) {
|
||||||
|
const bio = {
|
||||||
|
text: data.text,
|
||||||
|
source: data.source || 'Tidal',
|
||||||
|
};
|
||||||
|
await this.cache.set('artist', cacheKey, bio);
|
||||||
|
return bio;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch Tidal biography:', e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async getSimilarAlbums(albumId) {
|
async getSimilarAlbums(albumId) {
|
||||||
const cached = await this.cache.get('similar_albums', albumId);
|
const cached = await this.cache.get('similar_albums', albumId);
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,18 @@ export class MusicAPI {
|
||||||
return api.getArtist(cleanId);
|
return api.getArtist(cleanId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getArtistBiography(id, provider = null) {
|
||||||
|
const p = provider || this.getProviderFromId(id) || this.getCurrentProvider();
|
||||||
|
if (p !== 'tidal') return null; // Biography only supported for Tidal
|
||||||
|
|
||||||
|
const api = this.getAPI(p);
|
||||||
|
const cleanId = this.stripProviderPrefix(id);
|
||||||
|
if (typeof api.getArtistBiography === 'function') {
|
||||||
|
return api.getArtistBiography(cleanId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async getPlaylist(id, _provider = null) {
|
async getPlaylist(id, _provider = null) {
|
||||||
// Playlists are always Tidal for now
|
// Playlists are always Tidal for now
|
||||||
return this.tidalAPI.getPlaylist(id);
|
return this.tidalAPI.getPlaylist(id);
|
||||||
|
|
|
||||||
163
js/ui.js
163
js/ui.js
|
|
@ -2871,6 +2871,7 @@ export class UIRenderer {
|
||||||
const imageEl = document.getElementById('artist-detail-image');
|
const imageEl = document.getElementById('artist-detail-image');
|
||||||
const nameEl = document.getElementById('artist-detail-name');
|
const nameEl = document.getElementById('artist-detail-name');
|
||||||
const metaEl = document.getElementById('artist-detail-meta');
|
const metaEl = document.getElementById('artist-detail-meta');
|
||||||
|
const bioEl = document.getElementById('artist-detail-bio');
|
||||||
const tracksContainer = document.getElementById('artist-detail-tracks');
|
const tracksContainer = document.getElementById('artist-detail-tracks');
|
||||||
const albumsContainer = document.getElementById('artist-detail-albums');
|
const albumsContainer = document.getElementById('artist-detail-albums');
|
||||||
const epsContainer = document.getElementById('artist-detail-eps');
|
const epsContainer = document.getElementById('artist-detail-eps');
|
||||||
|
|
@ -2884,6 +2885,11 @@ export class UIRenderer {
|
||||||
imageEl.style.backgroundColor = 'var(--muted)';
|
imageEl.style.backgroundColor = 'var(--muted)';
|
||||||
nameEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
|
nameEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
|
||||||
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 150px;"></div>';
|
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 150px;"></div>';
|
||||||
|
if (bioEl) {
|
||||||
|
bioEl.style.display = 'none';
|
||||||
|
bioEl.textContent = '';
|
||||||
|
bioEl.classList.remove('expanded');
|
||||||
|
}
|
||||||
tracksContainer.innerHTML = this.createSkeletonTracks(5, true);
|
tracksContainer.innerHTML = this.createSkeletonTracks(5, true);
|
||||||
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||||
if (epsContainer) epsContainer.innerHTML = this.createSkeletonCards(6, false);
|
if (epsContainer) epsContainer.innerHTML = this.createSkeletonCards(6, false);
|
||||||
|
|
@ -2896,6 +2902,163 @@ export class UIRenderer {
|
||||||
try {
|
try {
|
||||||
const artist = await this.api.getArtist(artistId, provider);
|
const artist = await this.api.getArtist(artistId, provider);
|
||||||
|
|
||||||
|
// Handle Biography
|
||||||
|
if (bioEl) {
|
||||||
|
// Pre-define regex patterns for better performance
|
||||||
|
const linkTypes = ['artist', 'album', 'track', 'playlist'];
|
||||||
|
const regexCache = {
|
||||||
|
wimp: linkTypes.reduce((acc, type) => {
|
||||||
|
acc[type] = new RegExp(`\\[wimpLink ${type}Id="([a-f\\d-]+)"\\](.*?)\\[\\/wimpLink\\]`, 'g');
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
legacy: linkTypes.reduce((acc, type) => {
|
||||||
|
acc[type] = new RegExp(`\\[${type}:([a-f\\d-]+)\\](.*?)\\[\\/${type}\\]`, 'g');
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
doubleBracket: /\[\[(.*?)\|(.*?)\]\]/g,
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseBio = (text) => {
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
let parsed = text;
|
||||||
|
|
||||||
|
linkTypes.forEach((type) => {
|
||||||
|
parsed = parsed.replace(
|
||||||
|
regexCache.wimp[type],
|
||||||
|
(_m, id, name) =>
|
||||||
|
`<span class="bio-link" data-type="${type}" data-id="${id}">${name}</span>`
|
||||||
|
);
|
||||||
|
parsed = parsed.replace(
|
||||||
|
regexCache.legacy[type],
|
||||||
|
(_m, id, name) =>
|
||||||
|
`<span class="bio-link" data-type="${type}" data-id="${id}">${name}</span>`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
parsed = parsed.replace(
|
||||||
|
regexCache.doubleBracket,
|
||||||
|
(_m, name, id) => `<span class="bio-link" data-type="artist" data-id="${id}">${name}</span>`
|
||||||
|
);
|
||||||
|
|
||||||
|
return parsed.replace(/\n/g, '<br>');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to strip tags for clean preview
|
||||||
|
const stripBioTags = (text) => {
|
||||||
|
if (!text) return '';
|
||||||
|
let clean = text;
|
||||||
|
linkTypes.forEach((type) => {
|
||||||
|
// [wimpLink artistId="..."]Name[/wimpLink] -> Name
|
||||||
|
clean = clean.replace(regexCache.wimp[type], (_m, _id, name) => name);
|
||||||
|
// [artist:...]Name[/artist] -> Name
|
||||||
|
clean = clean.replace(regexCache.legacy[type], (_m, _id, name) => name);
|
||||||
|
});
|
||||||
|
// [[Name|ID]] -> Name
|
||||||
|
clean = clean.replace(regexCache.doubleBracket, (_m, name, _id) => name);
|
||||||
|
return clean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showBioModal = (bio) => {
|
||||||
|
const text = typeof bio === 'string' ? bio : bio.text;
|
||||||
|
const source = typeof bio === 'string' ? null : bio.source;
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal active bio-modal';
|
||||||
|
modal.style.zIndex = '9999'; // Ensure it's on top
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-overlay"></div>
|
||||||
|
<div class="modal-content extra-wide" style="display: flex; flex-direction: column;">
|
||||||
|
<div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem;">
|
||||||
|
<h3 style="margin: 0;">Artist Biography</h3>
|
||||||
|
<button class="btn-close" style="background: none; border: none; font-size: 2rem; cursor: pointer; color: var(--foreground); padding: 0.2rem 0.5rem; line-height: 1;">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height: 70vh; overflow-y: auto; line-height: 1.8; font-size: 1.1rem; padding-right: 1rem; color: var(--foreground); cursor: default;">
|
||||||
|
${parseBio(text)}
|
||||||
|
${source ? `<div class="bio-source">Source: ${source}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const close = (e) => {
|
||||||
|
if (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
modal.remove();
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.querySelector('.modal-overlay').onclick = close;
|
||||||
|
modal.querySelector('.btn-close').onclick = close;
|
||||||
|
|
||||||
|
// Ensure links are clickable by attaching the listener to the modal body
|
||||||
|
const modalBody = modal.querySelector('.modal-body');
|
||||||
|
modalBody.addEventListener(
|
||||||
|
'click',
|
||||||
|
(e) => {
|
||||||
|
const link = e.target.closest('.bio-link');
|
||||||
|
if (link) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const { type, id } = link.dataset;
|
||||||
|
if (type && id) {
|
||||||
|
modal.remove();
|
||||||
|
navigate(`/${type}/t/${id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true
|
||||||
|
); // Use capture phase to ensure it's hit
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBioPreview = (bio) => {
|
||||||
|
const text = typeof bio === 'string' ? bio : bio.text;
|
||||||
|
if (text) {
|
||||||
|
// Use stripped text for preview to avoid broken tags/links
|
||||||
|
const cleanText = stripBioTags(text);
|
||||||
|
const isLong = cleanText.length > 200;
|
||||||
|
const previewText = isLong ? cleanText.substring(0, 200).trim() + '...' : cleanText;
|
||||||
|
|
||||||
|
bioEl.innerHTML = previewText.replace(/\n/g, '<br>');
|
||||||
|
bioEl.style.display = 'block';
|
||||||
|
bioEl.style.webkitLineClamp = 'unset';
|
||||||
|
bioEl.style.cursor = 'default';
|
||||||
|
bioEl.onclick = null;
|
||||||
|
|
||||||
|
if (isLong) {
|
||||||
|
bioEl.appendChild(document.createElement('br'));
|
||||||
|
const readMore = document.createElement('span');
|
||||||
|
readMore.className = 'bio-read-more';
|
||||||
|
readMore.textContent = 'Read More';
|
||||||
|
readMore.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
showBioModal(bio);
|
||||||
|
};
|
||||||
|
bioEl.appendChild(readMore);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bioEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (artist.biography) {
|
||||||
|
renderBioPreview(artist.biography);
|
||||||
|
} else {
|
||||||
|
// Try to fetch biography asynchronously
|
||||||
|
this.api
|
||||||
|
.getArtistBiography(artistId, provider)
|
||||||
|
.then((bio) => {
|
||||||
|
if (bio) renderBioPreview(bio);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Artist Mix Button
|
// Handle Artist Mix Button
|
||||||
const mixBtn = document.getElementById('artist-mix-btn');
|
const mixBtn = document.getElementById('artist-mix-btn');
|
||||||
if (mixBtn) {
|
if (mixBtn) {
|
||||||
|
|
|
||||||
57
styles.css
57
styles.css
|
|
@ -2360,6 +2360,59 @@ input[type='search']::-webkit-search-cancel-button {
|
||||||
color: var(--highlight);
|
color: var(--highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Artist Biography Styles */
|
||||||
|
.artist-bio {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
max-width: 600px;
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: color var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-bio:hover {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-link {
|
||||||
|
color: var(--highlight) !important;
|
||||||
|
text-decoration: underline !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-link:hover {
|
||||||
|
color: var(--primary) !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-read-more {
|
||||||
|
display: block;
|
||||||
|
color: var(--highlight);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-read-more:hover {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-source {
|
||||||
|
display: block;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-header-actions {
|
.detail-header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
|
@ -4999,6 +5052,10 @@ img[src=''] {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-content.extra-wide {
|
||||||
|
max-width: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content.medium {
|
.modal-content.medium {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue