video covers
This commit is contained in:
parent
71b65e70a8
commit
2e1367e5c2
7 changed files with 250 additions and 49 deletions
26
index.html
26
index.html
|
|
@ -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">
|
||||
|
|
|
|||
16
js/api.js
16
js/api.js
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
81
js/player.js
81
js/player.js
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
64
js/ui.js
64
js/ui.js
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
91
styles.css
91
styles.css
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue