video covers

This commit is contained in:
edideaur 2026-03-09 21:54:32 +00:00
parent 71b65e70a8
commit 2e1367e5c2
7 changed files with 250 additions and 49 deletions

View file

@ -376,12 +376,30 @@
stroke-linejoin="round"
class="lucide lucide-repeat-icon lucide-repeat"
>
<path d="m17 2 4 4-4 4" />
<path d="M3 11v-1a4 4 0 0 1 4-4h14" />
<path d="m7 22-4-4 4-4" />
<path d="M21 13v1a4 4 0 0 1-4 4H3" />
<path d="m17 2 4 4-4 4"></path>
<path d="M3 11v-1a4 4 0 0 1 4-4h14"></path>
<path d="m7 22-4-4 4-4"></path>
<path d="M21 13v1a4 4 0 0 1-4 4H3"></path>
</svg>
</button>
<button id="fs-quality-btn" class="fs-quality-btn" title="Quality" style="display: none">
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 20h9"></path>
<path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"></path>
</svg>
<span class="fs-quality-label">Auto</span>
</button>
<div id="fs-quality-menu" class="fs-quality-menu" style="display: none"></div>
</div>
<div class="fullscreen-volume-container">
<button id="fs-volume-btn" class="fs-volume-btn" title="Mute">

View file

@ -1547,6 +1547,22 @@ export class LosslessAPI {
return `https://resources.tidal.com/images/${formattedId}/${size}x${size}.jpg`;
}
getVideoCoverUrl(imageId, size = '1280') {
if (!imageId) {
return null;
}
if (
typeof imageId === 'string' &&
(imageId.startsWith('http') || imageId.startsWith('blob:') || imageId.startsWith('assets/'))
) {
return imageId;
}
const formattedId = String(imageId).replace(/-/g, '/');
return `https://resources.tidal.com/images/${formattedId}/${size}x720.jpg`;
}
async clearCache() {
await this.cache.clear();
this.streamCache.clear();

View file

@ -153,6 +153,19 @@ export class MusicAPI {
return this.tidalAPI.getCoverUrl(id, size);
}
getVideoCoverUrl(imageId, size = '1280') {
if (!imageId) {
return null;
}
if (typeof imageId === 'string' && imageId.startsWith('blob:')) {
return imageId;
}
if (typeof imageId === 'string' && imageId.startsWith('q:')) {
return null;
}
return this.tidalAPI.getVideoCoverUrl(imageId, size);
}
async getVideoArtwork(title, artist) {
const cacheKey = `${title}-${artist}`.toLowerCase();
if (this.videoArtworkCache.has(cacheKey)) {

View file

@ -452,7 +452,7 @@ export class Player {
}
setupHlsVideo(video, result, fallbackImg) {
const url = result.videoUrl || result.hlsUrl || result; // Allow passing just the URL
const url = result.videoUrl || result.hlsUrl || result;
if (!url) return;
if (this.hls) {
@ -460,12 +460,20 @@ export class Player {
this.hls = null;
}
const qualityBtn = document.getElementById('fs-quality-btn');
const qualityMenu = document.getElementById('fs-quality-menu');
if (qualityBtn) qualityBtn.style.display = 'none';
if (qualityMenu) qualityMenu.style.display = 'none';
if (typeof url === 'string' && (url.includes('.m3u8') || url.includes('application/vnd.apple.mpegurl'))) {
if (Hls.isSupported()) {
this.hls = new Hls();
this.hls.loadSource(url);
this.hls.attachMedia(video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {});
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch(() => {});
this.setupVideoQualitySelector();
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.warn('HLS fatal error:', data.type);
@ -491,6 +499,75 @@ export class Player {
}
}
setupVideoQualitySelector() {
if (!this.hls || !this.hls.levels || this.hls.levels.length === 0) return;
const qualityBtn = document.getElementById('fs-quality-btn');
const qualityMenu = document.getElementById('fs-quality-menu');
if (!qualityBtn || !qualityMenu) return;
const levels = this.hls.levels;
const qualityLabels = [
'Auto',
...levels.map((level, i) => {
const height = level.height || 0;
const bandwidth = level.bitrate || 0;
if (height >= 1080) return '1080p';
if (height >= 720) return '720p';
if (height >= 480) return '480p';
if (height >= 360) return '360p';
if (height >= 180) return '180p';
return `${Math.round(bandwidth / 1000)}k`;
}),
];
const updateQualityMenu = () => {
const currentLevel = this.hls.currentLevel;
qualityMenu.innerHTML = qualityLabels
.map((label, i) => {
const isActive = currentLevel === i - 1 || (i === 0 && currentLevel === -1);
return `<button class="fs-quality-option ${isActive ? 'active' : ''}" data-level="${i - 1}">${label}</button>`;
})
.join('');
qualityMenu.querySelectorAll('.fs-quality-option').forEach((btn) => {
btn.onclick = (e) => {
e.stopPropagation();
const level = parseInt(btn.dataset.level);
this.hls.currentLevel = level;
const labelSpan = qualityBtn.querySelector('.fs-quality-label');
if (labelSpan) labelSpan.textContent = level === -1 ? 'Auto' : qualityLabels[level + 1] || 'Auto';
qualityMenu.style.display = 'none';
};
});
};
qualityBtn.style.display = 'flex';
qualityBtn.onclick = (e) => {
e.stopPropagation();
const isVisible = qualityMenu.style.display === 'block';
qualityMenu.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
updateQualityMenu();
}
};
this.hls.on(Hls.Events.LEVEL_SWITCHED, () => {
updateQualityMenu();
const labelSpan = qualityBtn.querySelector('.fs-quality-label');
if (labelSpan) {
const currentLevel = this.hls.currentLevel;
labelSpan.textContent = currentLevel === -1 ? 'Auto' : qualityLabels[currentLevel + 1] || 'Auto';
}
});
document.addEventListener('click', () => {
qualityMenu.style.display = 'none';
});
qualityMenu.onclick = (e) => e.stopPropagation();
}
async playVideo(video) {
if (!video) return;
const videoTrack = {

View file

@ -251,6 +251,12 @@ export function initializeUIInteractions(player, api, ui) {
? `title="Blocked: ${contentBlockingSettings.isTrackBlocked(track.id) ? 'Track blocked' : contentBlockingSettings.isArtistBlocked(track.artist?.id) ? 'Artist blocked' : 'Album blocked'}"`
: '';
const isVideo = track.type === 'video';
const coverUrl =
isVideo && track.imageId
? api.getVideoCoverUrl(track.imageId)
: api.getCoverUrl(track.album?.cover);
return `
<div class="queue-track-item ${isPlaying ? 'playing' : ''} ${isBlocked ? 'blocked' : ''}" data-queue-index="${index}" data-track-id="${track.id}" draggable="${isBlocked ? 'false' : 'true'}" ${blockedTitle}>
<div class="drag-handle">
@ -260,7 +266,7 @@ export function initializeUIInteractions(player, api, ui) {
</svg>
</div>
<div class="track-item-info">
<img src="${api.getCoverUrl(track.album?.cover)}"
<img src="${coverUrl}"
class="track-item-cover" loading="lazy">
<div class="track-item-details">
<div class="title">${escapeHtml(trackTitle)} ${qualityBadge}</div>

View file

@ -348,9 +348,19 @@ export class UIRenderer {
let trackImageHTML = '';
if (showCover) {
if (isVideo && this.currentPage === 'playlist') {
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.7;"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg></div>`;
const videoCoverUrl = this.api.getVideoCoverUrl(track.imageId);
if (videoCoverUrl) {
trackImageHTML = `<img src="${videoCoverUrl}" alt="" class="track-item-cover" loading="lazy">`;
} else {
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity: 0.7;"><path d="m22 8-6 4 6 4V8Z"/><rect width="14" height="12" x="2" y="6" rx="2" ry="2"/></svg></div>`;
}
} else if (isVideo && (this.currentPage === 'search' || this.currentPage === 'library')) {
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M8 5v14l11-7z"/></svg></div>`;
const videoCoverUrl = this.api.getVideoCoverUrl(track.imageId);
if (videoCoverUrl) {
trackImageHTML = `<img src="${videoCoverUrl}" alt="" class="track-item-cover" loading="lazy">`;
} else {
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M8 5v14l11-7z"/></svg></div>`;
}
} else {
trackImageHTML = this.getCoverHTML(
track.image || track.cover || track.album?.cover,
@ -670,10 +680,13 @@ export class UIRenderer {
const duration = formatTime(video.duration);
const artistName = getTrackArtists(video);
const videoCoverUrl = this.api.getVideoCoverUrl(video.imageId);
const cover = video.image || video.cover;
let imageHTML;
if (cover) {
if (videoCoverUrl) {
imageHTML = `<img src="${videoCoverUrl}" alt="${escapeHtml(video.title)}" class="card-image" loading="lazy">`;
} else if (cover) {
imageHTML = this.getCoverHTML(cover, escapeHtml(video.title));
} else {
imageHTML = `<div class="card-image video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary); aspect-ratio: 16/9; width: 100%;"><svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.7;"><path d="M8 5v14l11-7z"/></svg></div>`;
@ -1012,6 +1025,11 @@ export class UIRenderer {
if (image) image.style.display = 'block';
if (visualizerContainer) visualizerContainer.style.display = 'block';
const qualityBtn = document.getElementById('fs-quality-btn');
const qualityMenu = document.getElementById('fs-quality-menu');
if (qualityBtn) qualityBtn.style.display = 'none';
if (qualityMenu) qualityMenu.style.display = 'none';
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover, '1280');
@ -1227,50 +1245,20 @@ export class UIRenderer {
// Mouse move handler
const handleMouseMove = (e) => {
const rect = overlay.getBoundingClientRect();
// Check if mouse is near the top-right corner (within 150px from right, 100px from top)
const isNearTopRight = e.clientY < 100 && e.clientX > rect.width - 150;
if (isUIHidden) {
if (overlay.classList.contains('is-video-mode')) {
toggleUI();
if (isNearTopRight) {
showButton();
} else {
hideButton();
}
} else if (isNearTopRight) {
showButton();
} else {
hideButton();
}
} else if (overlay.classList.contains('is-video-mode')) {
resetVideoHideTimer();
}
};
let videoHideTimer = null;
const resetVideoHideTimer = () => {
if (videoHideTimer) clearTimeout(videoHideTimer);
if (!overlay.classList.contains('is-video-mode') || isUIHidden) return;
videoHideTimer = setTimeout(() => {
if (!isUIHidden && overlay.classList.contains('is-video-mode')) {
toggleUI();
}
}, 3000);
};
resetVideoHideTimer();
// Toggle UI visibility
const toggleUI = () => {
isUIHidden = !isUIHidden;
overlay.classList.toggle('ui-hidden', isUIHidden);
toggleBtn.classList.toggle('active', isUIHidden);
toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI';
if (isUIHidden) {
// When UI is hidden, immediately hide the button
// It will reappear when mouse nears top-right
hideButton();
} else {
// When UI is shown, keep button visible
showButton();
}
};

View file

@ -2113,6 +2113,10 @@ input[type='search']::-webkit-search-cancel-button {
color: var(--highlight);
}
.track-item.video-track-item {
gap: var(--spacing-xl);
}
.track-item.unavailable {
opacity: 0.5;
cursor: not-allowed;
@ -5436,12 +5440,12 @@ img[src=''] {
right: 2rem;
max-width: 500px;
margin: 0 auto;
background: rgb(15, 15, 15, 0.5);
backdrop-filter: blur(12px);
background: transparent;
backdrop-filter: none;
padding: 0.6rem 1rem;
border-radius: 10px;
border: 1px solid rgb(255, 255, 255, 0.05);
box-shadow: 0 4px 20px rgb(0, 0, 0, 0.4);
border: none;
box-shadow: none;
transition:
transform 0.4s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.4s ease;
@ -5478,6 +5482,84 @@ img[src=''] {
display: none;
}
#fullscreen-cover-overlay.is-video-mode .fs-quality-btn {
display: flex !important;
width: 28px;
height: 28px;
padding: 0.25rem;
}
#fullscreen-cover-overlay.is-video-mode .fs-quality-btn svg {
width: 18px;
height: 18px;
}
#fullscreen-cover-overlay.is-video-mode .fs-quality-label {
display: none;
}
.fs-quality-btn {
background: transparent;
border: none;
color: white;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 4px;
opacity: 0.7;
transition: opacity 0.2s;
position: relative;
}
.fs-quality-btn:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.1);
}
.fullscreen-volume-container {
position: relative;
}
.fs-quality-menu {
position: absolute;
bottom: 100%;
right: 0;
background: rgb(20, 20, 20);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 4px;
min-width: 120px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
margin-bottom: 8px;
}
.fs-quality-option {
display: block;
width: 100%;
padding: 8px 12px;
border: none;
background: transparent;
color: white;
text-align: left;
cursor: pointer;
font-size: 0.85rem;
border-radius: 4px;
transition: background 0.2s;
}
.fs-quality-option:hover {
background: rgba(255, 255, 255, 0.1);
}
.fs-quality-option.active {
background: var(--primary);
color: white;
}
#fullscreen-cover-overlay.is-video-mode .fullscreen-volume-container {
margin-top: 0.5rem;
}
@ -8193,6 +8275,7 @@ body:has(#side-panel.active) #close-fullscreen-cover-btn {
.video-card .card-image-container {
aspect-ratio: 16 / 9 !important;
margin-bottom: var(--spacing-sm);
}
.video-card .card-image {