feat(artists): Artist Banners

This commit is contained in:
Samidy 2026-04-13 14:55:13 +03:00
parent 4f36b60c82
commit 7dd38bac80
6 changed files with 217 additions and 0 deletions

View file

@ -2603,6 +2603,7 @@
<div id="page-artist" class="page">
<header class="detail-header">
<div id="artist-detail-banner-container" class="detail-header-banner"></div>
<img
id="artist-detail-image"
src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"
@ -3392,6 +3393,16 @@
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Artist Banners</span>
<span class="description">Display video banners on artist pages</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="artist-banners-toggle" checked />
<span class="slider"></span>
</label>
</div>
<div class="setting-item">
<div class="info">
<span class="label">Compact Albums</span>

View file

@ -277,6 +277,47 @@ export class MusicAPI {
return this.tidalAPI.getArtistPictureSrcset(this.stripProviderPrefix(id));
}
async getArtistBanner(artistName) {
const cacheKey = `banner-${artistName}`.toLowerCase();
if (this.videoArtworkCache.has(cacheKey)) {
return this.videoArtworkCache.get(cacheKey);
}
try {
const url = `https://artwork-boidu-dev.samidy.workers.dev/artist?a=${encodeURIComponent(artistName)}`;
const response = await fetch(url);
if (!response.ok) return null;
const data = await response.json();
let hlsUrl = null;
if (data.animated) {
if (typeof data.animated === 'string') {
hlsUrl = data.animated;
} else if (typeof data.animated === 'object') {
hlsUrl = data.animated.hls || data.animated.url || data.animated.hlsUrl || data.animated.videoUrl;
if (!hlsUrl) {
for (const key in data.animated) {
if (typeof data.animated[key] === 'string' && data.animated[key].includes('.m3u8')) {
hlsUrl = data.animated[key];
break;
}
}
}
}
}
const result = {
hlsUrl: hlsUrl,
};
this.videoArtworkCache.set(cacheKey, result);
return result;
} catch (error) {
console.warn('Failed to fetch artist banner:', error);
return null;
}
}
extractStreamUrlFromManifest(manifest) {
return this.tidalAPI.extractStreamUrlFromManifest(manifest);
}

View file

@ -8,6 +8,7 @@ import {
backgroundSettings,
dynamicColorSettings,
cardSettings,
artistBannerSettings,
waveformSettings,
replayGainSettings,
downloadQualitySettings,
@ -5675,6 +5676,15 @@ export async function initializeSettings(scrobbler, player, api, ui) {
});
}
// Artist Banners Toggle
const artistBannersToggle = document.getElementById('artist-banners-toggle');
if (artistBannersToggle) {
artistBannersToggle.checked = artistBannerSettings.isEnabled();
artistBannersToggle.addEventListener('change', (e) => {
artistBannerSettings.setEnabled(e.target.checked);
});
}
// Compact Album Toggle
const compactAlbumToggle = document.getElementById('compact-album-toggle');
if (compactAlbumToggle) {

View file

@ -687,6 +687,23 @@ export const cardSettings = {
},
};
export const artistBannerSettings = {
STORAGE_KEY: 'artist-banners-enabled',
isEnabled() {
try {
const val = localStorage.getItem(this.STORAGE_KEY);
return val === null ? true : val === 'true';
} catch {
return true;
}
},
setEnabled(enabled) {
localStorage.setItem(this.STORAGE_KEY, enabled ? 'true' : 'false');
},
};
export const replayGainSettings = {
STORAGE_KEY_MODE: 'replay-gain-mode', // 'off', 'track', 'album'
STORAGE_KEY_PREAMP: 'replay-gain-preamp',

View file

@ -29,6 +29,7 @@ import {
contentBlockingSettings,
settingsUiState,
fullscreenCoverNoRoundSettings,
artistBannerSettings,
} from './storage.js';
import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js';
@ -4775,6 +4776,16 @@ export class UIRenderer {
await this.showPage('artist');
this.currentArtistId = artistId;
const bannerContainer = document.getElementById('artist-detail-banner-container');
if (bannerContainer) {
const oldVideo = bannerContainer.querySelector('video');
if (oldVideo && oldVideo._hls) {
oldVideo._hls.destroy();
}
bannerContainer.innerHTML = '';
bannerContainer.style.opacity = '0';
}
const imageEl = document.getElementById('artist-detail-image');
const nameEl = document.getElementById('artist-detail-name');
const metaEl = document.getElementById('artist-detail-meta');
@ -4823,6 +4834,39 @@ export class UIRenderer {
try {
const artist = await this.api.getArtist(artistId, provider);
const currentId = this.currentArtistId;
this.api
.getArtistBanner(artist.name)
.then(async (banner) => {
if (this.currentArtistId !== currentId) return;
if (banner && banner.hlsUrl && bannerContainer) {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.setAttribute('muted', '');
video.setAttribute('autoplay', '');
video.setAttribute('playsinline', '');
video.style.opacity = '1';
try {
await this.setupHlsVideo(video, banner, null);
if (this.currentArtistId === currentId) {
bannerContainer.appendChild(video);
bannerContainer.style.opacity = '1';
video.play().catch(() => {});
}
} catch (e) {
console.warn('Failed to setup artist banner video:', e);
}
}
})
.catch((e) => {
console.warn('Failed to fetch artist banner:', e);
});
// Handle Biography
if (bioEl) {
// Pre-define regex patterns for better performance

View file

@ -2542,6 +2542,100 @@ body.multi-select-mode .track-item:hover {
border-radius: var(--radius-full);
}
.detail-header-banner {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
opacity: 0;
transition: opacity 1s ease-in-out;
}
.detail-header-banner video {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.6);
display: block;
}
#page-artist .detail-header {
position: relative;
padding: 12rem 3rem 4rem 3rem;
border-radius: 0;
overflow: hidden;
margin-top: -8rem;
margin-left: calc(var(--spacing-xl) * -1);
margin-right: calc(var(--spacing-xl) * -1);
margin-bottom: var(--spacing-xl);
min-height: 550px;
display: flex;
align-items: flex-end;
background-color: var(--card);
}
@media (max-width: 1024px) {
#page-artist .detail-header {
margin-top: -7rem;
margin-left: calc(var(--spacing-lg) * -1);
margin-right: calc(var(--spacing-lg) * -1);
padding: 10rem 2rem 3rem 2rem;
min-height: 450px;
}
}
@media (max-width: 768px) {
#page-artist .detail-header {
margin-top: -6rem;
margin-left: calc(var(--spacing-md) * -1);
margin-right: calc(var(--spacing-md) * -1);
padding: 8rem 1rem 2rem 1rem;
min-height: 400px;
}
}
@media (max-width: 480px) {
#page-artist .detail-header {
margin-top: calc((var(--spacing-sm) + var(--spacing-xl)) * -1);
margin-left: calc(var(--spacing-sm) * -1);
margin-right: calc(var(--spacing-sm) * -1);
}
}
.detail-header-banner::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 0.4) 0%,
rgba(0, 0, 0, 0) 40%,
rgba(0, 0, 0, 0.2) 70%,
var(--background) 100%
);
z-index: 1;
}
#page-artist .detail-header-image {
width: 200px;
height: 200px;
border: 4px solid var(--background);
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.6);
z-index: 2;
}
#page-artist .detail-header-info {
z-index: 2;
text-shadow: 0 2px 15px rgba(0, 0, 0, 0.7);
}
.detail-header-info .type {
font-weight: 600;
margin-bottom: 0.5rem;