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 `
`;
+ return `
`;
}
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: `