From e3f781d58811663c774cbb9bd0b97f9f348ec050 Mon Sep 17 00:00:00 2001 From: Samidy Date: Sun, 8 Mar 2026 05:27:20 +0300 Subject: [PATCH] feat(covers): animated covers --- bun.lock | 3 + js/music-api.js | 24 +++++ js/player.js | 86 ++++++++++++++- js/ui.js | 271 +++++++++++++++++++++++++++++++++++++++++++++--- package.json | 1 + 5 files changed, 370 insertions(+), 15 deletions(-) diff --git a/bun.lock b/bun.lock index 5046b04..63f4a92 100644 --- a/bun.lock +++ b/bun.lock @@ -15,6 +15,7 @@ "cookie-session": "^2.1.1", "dashjs": "^5.1.1", "fuse.js": "^7.1.0", + "hls.js": "^1.6.15", "jose": "^6.1.3", "npm": "^11.11.0", "pocketbase": "^0.26.8", @@ -843,6 +844,8 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hls.js": ["hls.js@1.6.15", "", {}, "sha512-E3a5VwgXimGHwpRGV+WxRTKeSp2DW5DI5MWv34ulL3t5UNmyJWCQ1KmLEHbYzcfThfXG8amBL+fCYPneGHC4VA=="], + "hookified": ["hookified@1.15.0", "", {}, "sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw=="], "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="], diff --git a/js/music-api.js b/js/music-api.js index adb8ee2..0f860d8 100644 --- a/js/music-api.js +++ b/js/music-api.js @@ -10,6 +10,7 @@ export class MusicAPI { this.tidalAPI = new LosslessAPI(settings); this.qobuzAPI = new QobuzAPI(); this._settings = settings; + this.videoArtworkCache = new Map(); } getCurrentProvider() { @@ -139,6 +140,29 @@ export class MusicAPI { return this.getCoverUrl(fallbackCoverId, size); } + async getVideoArtwork(title, artist) { + const cacheKey = `${title}-${artist}`.toLowerCase(); + if (this.videoArtworkCache.has(cacheKey)) { + return this.videoArtworkCache.get(cacheKey); + } + + try { + const url = `https://artwork.boidu.dev/?s=${encodeURIComponent(title)}&a=${encodeURIComponent(artist)}`; + const response = await fetch(url); + if (!response.ok) return null; + const data = await response.json(); + const result = { + videoUrl: data.videoUrl || null, + hlsUrl: data.animated || null + }; + this.videoArtworkCache.set(cacheKey, result); + return result; + } catch (error) { + console.warn('Failed to fetch video artwork:', error); + return null; + } + } + getArtistPictureUrl(id, size = '320') { if (typeof id === 'string' && id.startsWith('q:')) { return this.qobuzAPI.getArtistPictureUrl(id.slice(2), size); diff --git a/js/player.js b/js/player.js index 21c2d89..2b18861 100644 --- a/js/player.js +++ b/js/player.js @@ -18,6 +18,7 @@ import { audioEffectsSettings, } from './storage.js'; import { audioContextManager } from './audio-context.js'; +import Hls from 'hls.js'; export class Player { constructor(audioElement, api, quality = 'HI_RES_LOSSLESS') { @@ -402,6 +403,42 @@ export class Player { } } + setupHlsVideo(video, result, fallbackImg) { + const url = result.videoUrl || result.hlsUrl; + if (!url) return; + + if (url.endsWith('.m3u8')) { + if (Hls.isSupported()) { + const hls = new Hls(); + hls.loadSource(url); + hls.attachMedia(video); + hls.on(Hls.Events.MANIFEST_PARSED, () => { + video.play().catch(() => {}); + }); + hls.on(Hls.Events.ERROR, (event, data) => { + if (data.fatal) { + console.warn('HLS fatal error:', data.type); + video.replaceWith(fallbackImg); + hls.destroy(); + } + }); + } else if (video.canPlayType('application/vnd.apple.mpegurl')) { + video.src = url; + } else { + video.replaceWith(fallbackImg); + } + } else { + video.src = url; + video.onerror = () => { + if (result.hlsUrl) { + this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg); + } else { + video.replaceWith(fallbackImg); + } + }; + } + } + async playTrackFromQueue(startTime = 0, recursiveCount = 0) { const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { @@ -433,22 +470,67 @@ export class Player { const coverEl = document.querySelector('.now-playing-bar .cover'); if (coverEl) { - const videoCoverUrl = track.album?.videoCover + let videoCoverUrl = track.album?.videoCover ? this.api.tidalAPI.getVideoCoverUrl(track.album.videoCover) - : null; + : track.album?.videoCoverUrl || null; + + if (!videoCoverUrl && track.album) { + this.api.getVideoArtwork(track.title, getTrackArtists(track)).then((result) => { + if (result && this.currentTrack?.id === track.id) { + const url = result.videoUrl || result.hlsUrl; + if (!url) return; + + track.album.videoCoverUrl = url; + const currentCoverEl = document.querySelector('.now-playing-bar .cover'); + if (currentCoverEl && currentCoverEl.tagName !== 'VIDEO') { + const video = document.createElement('video'); + video.autoplay = true; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.preload = 'auto'; + video.className = currentCoverEl.className; + video.id = currentCoverEl.id; + video.style.opacity = '1'; + video.style.zIndex = '1'; + video.style.objectFit = 'cover'; + video.poster = currentCoverEl.src; + + this.setupHlsVideo(video, result, currentCoverEl); + currentCoverEl.replaceWith(video); + } + } + }); + } + const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover); if (videoCoverUrl) { if (coverEl.tagName === 'IMG') { const video = document.createElement('video'); video.src = videoCoverUrl; + video.poster = this.api.getCoverUrl(track.album?.cover); video.autoplay = true; video.loop = true; video.muted = true; video.playsInline = true; + video.preload = 'auto'; video.className = coverEl.className; video.id = coverEl.id; + video.style.objectFit = 'cover'; + video.style.gridArea = '1 / 1'; + video.onerror = () => { + const img = document.createElement('img'); + img.src = this.api.getCoverUrl(track.album?.cover); + img.className = video.className; + img.id = video.id; + video.replaceWith(img); + }; coverEl.replaceWith(video); + } else if (coverEl.tagName === 'VIDEO' && coverEl.src !== videoCoverUrl) { + coverEl.src = videoCoverUrl; + coverEl.poster = this.api.getCoverUrl(track.album?.cover); + coverEl.style.objectFit = 'cover'; } } else { if (coverEl.tagName === 'VIDEO') { diff --git a/js/ui.js b/js/ui.js index 45ffb28..a30b994 100644 --- a/js/ui.js +++ b/js/ui.js @@ -49,6 +49,7 @@ import { createTrackFromSong, } from './tracker.js'; import { trackSearch, trackChangeSort } from './analytics.js'; +import Hls from 'hls.js'; fontSettings.applyFont(); fontSettings.applyFontSize(); @@ -412,12 +413,13 @@ export class UIRenderer { `; } - getCoverHTML(videoCover, cover, alt, className = 'card-image', loading = 'lazy') { - const videoUrl = videoCover ? this.api.tidalAPI.getVideoCoverUrl(videoCover) : null; + getCoverHTML(videoCover, cover, alt, className = 'card-image', loading = 'lazy', videoCoverUrl = null) { + const videoUrl = (videoCover ? this.api.tidalAPI.getVideoCoverUrl(videoCover) : null) || videoCoverUrl; + const imageUrl = this.api.getCoverUrl(cover); if (videoUrl) { - return ``; + return ``; } - return `${alt}`; + return `${alt}`; } createBaseCardHTML({ @@ -623,7 +625,7 @@ export class UIRenderer { href: `/album/${album.id}`, title: `${escapeHtml(album.title)} ${explicitBadge} ${qualityBadge}`, subtitle: `${escapeHtml(artistName)} • ${yearDisplay}${typeLabel}`, - imageHTML: this.getCoverHTML(album.videoCover, album.cover, escapeHtml(album.title)), + imageHTML: this.getCoverHTML(album.videoCover, album.cover, escapeHtml(album.title), 'card-image', 'lazy', album.videoCoverUrl), actionButtonsHTML: `