This commit is contained in:
Eduard Prigoana 2025-10-22 10:31:45 +03:00
parent 5be3311133
commit 0c9dec35ff
7 changed files with 417 additions and 182 deletions

105
js/api.js
View file

@ -309,60 +309,67 @@ export class LosslessAPI {
return result; return result;
} }
async getArtist(id) { async getArtist(artistId) {
const cached = await this.cache.get('artist', id); const cached = await this.cache.get('artist', artistId);
if (cached) return cached; if (cached) return cached;
const [primaryResponse, contentResponse] = await Promise.all([ const [primaryResponse, contentResponse] = await Promise.all([
this.fetchWithRetry(`/artist/?id=${id}`), this.fetchWithRetry(`/artist/?id=${artistId}`),
this.fetchWithRetry(`/artist/?f=${id}`) this.fetchWithRetry(`/artist/?f=${artistId}`)
]); ]);
const primaryData = await primaryResponse.json();
const rawArtist = Array.isArray(primaryData) ? primaryData[0] : primaryData;
if (!rawArtist) throw new Error('Primary artist details not found.');
// Ensure artist has required fields
const artist = {
...this.prepareArtist(rawArtist),
picture: rawArtist.picture || null,
name: rawArtist.name || 'Unknown Artist'
};
const contentData = await contentResponse.json();
const entries = Array.isArray(contentData) ? contentData : [contentData];
const albumMap = new Map();
const trackMap = new Map();
const isTrack = v => v?.id && v.duration && v.album;
const isAlbum = v => v?.id && 'numberOfTracks' in v;
const scan = (value, visited = new Set()) => {
if (!value || typeof value !== 'object' || visited.has(value)) return;
visited.add(value);
const primaryData = await primaryResponse.json(); if (Array.isArray(value)) {
const artist = this.prepareArtist(Array.isArray(primaryData) ? primaryData[0] : primaryData); value.forEach(item => scan(item, visited));
return;
}
if (!artist) throw new Error('Primary artist details not found.'); const item = value.item || value;
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
const contentData = await contentResponse.json(); Object.values(value).forEach(nested => scan(nested, visited));
const entries = Array.isArray(contentData) ? contentData : [contentData]; };
const albumMap = new Map(); entries.forEach(entry => scan(entry));
const trackMap = new Map();
const albums = Array.from(albumMap.values()).sort((a, b) =>
const isTrack = v => v?.id && v.duration && v.album; new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
const isAlbum = v => v?.id && v.cover && 'numberOfTracks' in v; );
const scan = (value, visited = new Set()) => { const tracks = Array.from(trackMap.values())
if (!value || typeof value !== 'object' || visited.has(value)) return; .sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
visited.add(value); .slice(0, 10);
if (Array.isArray(value)) { const result = { ...artist, albums, tracks };
value.forEach(item => scan(item, visited));
return;
}
const item = value.item || value;
if (isAlbum(item)) albumMap.set(item.id, this.prepareAlbum(item));
if (isTrack(item)) trackMap.set(item.id, this.prepareTrack(item));
Object.values(value).forEach(nested => scan(nested, visited));
};
entries.forEach(entry => scan(entry));
const albums = Array.from(albumMap.values()).sort((a, b) =>
new Date(b.releaseDate || 0) - new Date(a.releaseDate || 0)
);
const tracks = Array.from(trackMap.values())
.sort((a, b) => (b.popularity || 0) - (a.popularity || 0))
.slice(0, 10);
const result = { ...artist, albums, tracks };
await this.cache.set('artist', id, result); await this.cache.set('artist', artistId, result);
return result; return result;
} }
async getTrack(id, quality = 'LOSSLESS') { async getTrack(id, quality = 'LOSSLESS') {
const cacheKey = `${id}_${quality}`; const cacheKey = `${id}_${quality}`;

153
js/app.js
View file

@ -1,4 +1,3 @@
//app.js
import { LosslessAPI } from './api.js'; import { LosslessAPI } from './api.js';
import { apiSettings, themeManager, lastFMStorage } from './storage.js'; import { apiSettings, themeManager, lastFMStorage } from './storage.js';
import { UIRenderer } from './ui.js'; import { UIRenderer } from './ui.js';
@ -331,6 +330,21 @@ function completeBulkDownload(notifEl, success = true, message = null) {
} }
} }
async function loadHomeFeed(api) {
try {
const response = await api.fetchWithRetry('/home/');
const data = await response.json();
if (!Array.isArray(data) || data.length === 0) return null;
const homeData = data[0];
return homeData;
} catch (error) {
console.error('Failed to load home feed:', error);
return null;
}
}
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
const api = new LosslessAPI(apiSettings); const api = new LosslessAPI(apiSettings);
const ui = new UIRenderer(api); const ui = new UIRenderer(api);
@ -380,6 +394,8 @@ document.addEventListener('DOMContentLoaded', async () => {
const lastfmToggle = document.getElementById('lastfm-toggle'); const lastfmToggle = document.getElementById('lastfm-toggle');
const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting'); const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting');
window.loadHomeFeed = loadHomeFeed;
function updateLastFMUI() { function updateLastFMUI() {
if (scrobbler.isAuthenticated()) { if (scrobbler.isAuthenticated()) {
lastfmStatus.textContent = `Connected as ${scrobbler.username}`; lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
@ -397,75 +413,73 @@ document.addEventListener('DOMContentLoaded', async () => {
updateLastFMUI(); updateLastFMUI();
lastfmConnectBtn?.addEventListener('click', async () => { lastfmConnectBtn?.addEventListener('click', async () => {
if (scrobbler.isAuthenticated()) { if (scrobbler.isAuthenticated()) {
if (confirm('Disconnect from Last.fm?')) { if (confirm('Disconnect from Last.fm?')) {
scrobbler.disconnect(); scrobbler.disconnect();
updateLastFMUI(); updateLastFMUI();
} }
return;
}
const authWindow = window.open('', '_blank');
lastfmConnectBtn.disabled = true;
lastfmConnectBtn.textContent = 'Opening Last.fm...';
try {
const { token, url } = await scrobbler.getAuthUrl();
if (authWindow) {
authWindow.location.href = url;
} else {
alert('Popup blocked! Please allow popups.');
lastfmConnectBtn.textContent = 'Connect Last.fm';
lastfmConnectBtn.disabled = false;
return; return;
} }
lastfmConnectBtn.textContent = 'Waiting for authorization...'; const authWindow = window.open('', '_blank');
let attempts = 0; lastfmConnectBtn.disabled = true;
const maxAttempts = 30; lastfmConnectBtn.textContent = 'Opening Last.fm...';
const checkAuth = setInterval(async () => { try {
attempts++; const { token, url } = await scrobbler.getAuthUrl();
if (attempts > maxAttempts) { if (authWindow) {
clearInterval(checkAuth); authWindow.location.href = url;
} else {
alert('Popup blocked! Please allow popups.');
lastfmConnectBtn.textContent = 'Connect Last.fm'; lastfmConnectBtn.textContent = 'Connect Last.fm';
lastfmConnectBtn.disabled = false; lastfmConnectBtn.disabled = false;
if (authWindow && !authWindow.closed) authWindow.close();
alert('Authorization timed out. Please try again.');
return; return;
} }
try { lastfmConnectBtn.textContent = 'Waiting for authorization...';
const result = await scrobbler.completeAuthentication(token);
if (result.success) { let attempts = 0;
const maxAttempts = 30;
const checkAuth = setInterval(async () => {
attempts++;
if (attempts > maxAttempts) {
clearInterval(checkAuth); clearInterval(checkAuth);
if (authWindow && !authWindow.closed) authWindow.close(); lastfmConnectBtn.textContent = 'Connect Last.fm';
updateLastFMUI();
lastfmConnectBtn.disabled = false; lastfmConnectBtn.disabled = false;
lastFMStorage.setEnabled(true); if (authWindow && !authWindow.closed) authWindow.close();
lastfmToggle.checked = true; alert('Authorization timed out. Please try again.');
alert(`Successfully connected to Last.fm as ${result.username}!`); return;
} }
} catch (e) {
}
}, 2000);
} catch (error) { try {
console.error('Last.fm connection failed:', error); const result = await scrobbler.completeAuthentication(token);
alert('Failed to connect to Last.fm: ' + error.message);
lastfmConnectBtn.textContent = 'Connect Last.fm';
lastfmConnectBtn.disabled = false;
if (authWindow && !authWindow.closed) authWindow.close();
}
});
if (result.success) {
clearInterval(checkAuth);
if (authWindow && !authWindow.closed) authWindow.close();
updateLastFMUI();
lastfmConnectBtn.disabled = false;
lastFMStorage.setEnabled(true);
lastfmToggle.checked = true;
alert(`Successfully connected to Last.fm as ${result.username}!`);
}
} catch (e) {
}
}, 2000);
} catch (error) {
console.error('Last.fm connection failed:', error);
alert('Failed to connect to Last.fm: ' + error.message);
lastfmConnectBtn.textContent = 'Connect Last.fm';
lastfmConnectBtn.disabled = false;
if (authWindow && !authWindow.closed) authWindow.close();
}
});
lastfmToggle?.addEventListener('change', (e) => { lastfmToggle?.addEventListener('change', (e) => {
lastFMStorage.setEnabled(e.target.checked); lastFMStorage.setEnabled(e.target.checked);
@ -583,6 +597,19 @@ lastfmConnectBtn?.addEventListener('click', async () => {
}); });
} }
const normalizeToggle = document.querySelectorAll('.setting-item').forEach(item => {
const label = item.querySelector('.label');
if (label && label.textContent.includes('Normalize Volume')) {
const toggle = item.querySelector('input[type="checkbox"]');
if (toggle) {
toggle.checked = localStorage.getItem('normalize-volume') === 'true';
toggle.addEventListener('change', (e) => {
localStorage.setItem('normalize-volume', e.target.checked ? 'true' : 'false');
});
}
}
});
document.querySelector('.now-playing-bar .title').addEventListener('click', () => { document.querySelector('.now-playing-bar .title').addEventListener('click', () => {
const track = player.currentTrack; const track = player.currentTrack;
if (track?.album?.id) { if (track?.album?.id) {
@ -699,10 +726,6 @@ lastfmConnectBtn?.addEventListener('click', async () => {
}; };
const renderQueue = () => { const renderQueue = () => {
if (!queueModalOverlay.style.display || queueModalOverlay.style.display === "none") {
return;
}
const currentQueue = player.getCurrentQueue(); const currentQueue = player.getCurrentQueue();
if (currentQueue.length === 0) { if (currentQueue.length === 0) {
@ -814,6 +837,22 @@ lastfmConnectBtn?.addEventListener('click', async () => {
}); });
mainContent.addEventListener('click', e => { mainContent.addEventListener('click', e => {
const menuBtn = e.target.closest('.track-menu-btn');
if (menuBtn) {
e.stopPropagation();
const trackItem = menuBtn.closest('.track-item');
if (trackItem && !trackItem.dataset.queueIndex) {
contextTrack = trackDataStore.get(trackItem);
if (contextTrack) {
const rect = menuBtn.getBoundingClientRect();
contextMenu.style.top = `${rect.bottom + 5}px`;
contextMenu.style.left = `${rect.left}px`;
contextMenu.style.display = 'block';
}
}
return;
}
const trackItem = e.target.closest('.track-item'); const trackItem = e.target.closest('.track-item');
if (trackItem && !trackItem.dataset.queueIndex) { if (trackItem && !trackItem.dataset.queueIndex) {
const parentList = trackItem.closest('.track-list'); const parentList = trackItem.closest('.track-list');

View file

@ -1,3 +1,4 @@
//lastfm.js
import { delay } from './utils.js'; import { delay } from './utils.js';
export class LastFMScrobbler { export class LastFMScrobbler {

View file

@ -119,58 +119,74 @@ export class Player {
} }
} }
async playTrackFromQueue() { async playTrackFromQueue() {
const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue; const currentQueue = this.shuffleActive ? this.shuffledQueue : this.queue;
if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) { if (this.currentQueueIndex < 0 || this.currentQueueIndex >= currentQueue.length) {
return; return;
}
const track = currentQueue[this.currentQueueIndex];
this.currentTrack = track;
document.querySelector('.now-playing-bar .cover').src =
this.api.getCoverUrl(track.album?.cover, '1280');
document.querySelector('.now-playing-bar .title').textContent = track.title;
document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist';
document.title = `${track.title}${track.artist?.name || 'Unknown'}`;
this.updatePlayingTrackIndicator();
this.updateMediaSession(track);
try {
let streamUrl;
if (this.preloadCache.has(track.id)) {
streamUrl = this.preloadCache.get(track.id);
} else {
streamUrl = await this.api.getStreamUrl(track.id, this.quality);
}
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
const temp = this.audio;
this.audio = this.nextAudioElement;
this.nextAudioElement = temp;
this.nextAudioElement.pause();
this.nextAudioElement.currentTime = 0;
} else {
this.audio.src = streamUrl;
}
await this.audio.play();
this.isCrossfading = false;
this.updateMediaSessionPlaybackState();
this.preloadNextTracks();
this.setupCrossfadeListener();
} catch (error) {
console.error(`Could not play track: ${track.title}`, error);
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
}
} }
const track = currentQueue[this.currentQueueIndex];
this.currentTrack = track;
document.querySelector('.now-playing-bar .cover').src =
this.api.getCoverUrl(track.album?.cover, '1280');
document.querySelector('.now-playing-bar .title').textContent = track.title;
document.querySelector('.now-playing-bar .artist').textContent = track.artist?.name || 'Unknown Artist';
document.title = `${track.title}${track.artist?.name || 'Unknown'}`;
this.updatePlayingTrackIndicator();
this.updateMediaSession(track);
try {
let streamUrl;
if (this.preloadCache.has(track.id)) {
streamUrl = this.preloadCache.get(track.id);
} else {
const trackData = await this.api.getTrack(track.id, this.quality);
// Store replayGain for normalization
if (trackData.track?.replayGain !== undefined) {
window.currentGain = trackData.track.replayGain;
} else {
window.currentGain = track.replayGain || null;
}
if (trackData.originalTrackUrl) {
streamUrl = trackData.originalTrackUrl;
} else {
streamUrl = this.api.extractStreamUrlFromManifest(trackData.info.manifest);
}
}
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
const temp = this.audio;
this.audio = this.nextAudioElement;
this.nextAudioElement = temp;
this.nextAudioElement.pause();
this.nextAudioElement.currentTime = 0;
} else {
this.audio.src = streamUrl;
}
// Apply normalization if enabled
this.applyNormalization();
await this.audio.play();
this.isCrossfading = false;
this.updateMediaSessionPlaybackState();
this.preloadNextTracks();
this.setupCrossfadeListener();
} catch (error) {
console.error(`Could not play track: ${track.title}`, error);
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
}
}
setupCrossfadeListener() { setupCrossfadeListener() {
if (!this.crossfadeEnabled) return; if (!this.crossfadeEnabled) return;
@ -415,6 +431,16 @@ export class Player {
this.updateMediaSessionPlaybackState(); this.updateMediaSessionPlaybackState();
this.updateMediaSessionPositionState(); this.updateMediaSessionPositionState();
} }
applyNormalization() {
const normalizeEnabled = localStorage.getItem('normalize-volume') === 'true';
if (normalizeEnabled && window.currentGain !== null && window.currentGain !== undefined) {
const baseVolume = parseFloat(localStorage.getItem('base-volume') || '0.7');
const replayGain = parseFloat(window.currentGain);
const adjustment = Math.pow(10, replayGain / 20);
this.audio.volume = Math.min(1, Math.max(0, baseVolume * adjustment));
}
}
updateMediaSessionPlaybackState() { updateMediaSessionPlaybackState() {
if (!('mediaSession' in navigator)) return; if (!('mediaSession' in navigator)) return;
@ -441,4 +467,4 @@ export class Player {
console.debug('Failed to update Media Session position:', error); console.debug('Failed to update Media Session position:', error);
} }
} }
} }

View file

@ -1,3 +1,4 @@
//storage.js
export const apiSettings = { export const apiSettings = {
STORAGE_KEY: 'monochrome-api-instances', STORAGE_KEY: 'monochrome-api-instances',
INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json', INSTANCES_URL: 'https://raw.githubusercontent.com/EduardPrigoana/hifi-instances/refs/heads/main/instances.json',

119
js/ui.js
View file

@ -11,28 +11,46 @@ export class UIRenderer {
return '<span class="explicit-badge" title="Explicit">E</span>'; return '<span class="explicit-badge" title="Explicit">E</span>';
} }
createTrackMenuButton() {
return `
<button class="track-menu-btn" onclick="event.stopPropagation();" title="More options">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
</button>
`;
}
createTrackItemHTML(track, index, showCover = false) { createTrackItemHTML(track, index, showCover = false) {
const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>'; const playIconSmall = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>';
const trackNumberHTML = `<div class="track-number">${showCover ? playIconSmall : index + 1}</div>`; const trackNumberHTML = `<div class="track-number">${showCover ? playIconSmall : index + 1}</div>`;
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : ''; const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
return ` return `
<div class="track-item" data-track-id="${track.id}"> <div class="track-item" data-track-id="${track.id}">
${trackNumberHTML} ${trackNumberHTML}
<div class="track-item-info"> <div class="track-item-info">
${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''} ${showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : ''}
<div class="track-item-details"> <div class="track-item-details">
<div class="title"> <div class="title">
${track.title} ${track.title}
${explicitBadge} ${explicitBadge}
</div>
<div class="artist">${track.artist?.name ?? 'Unknown Artist'}</div>
</div> </div>
<div class="artist">${track.artist?.name ?? 'Unknown Artist'}</div>
</div> </div>
<div class="track-item-duration">${formatTime(track.duration)}</div>
</div> </div>
`; <div class="track-item-duration">${formatTime(track.duration)}</div>
} <button class="track-menu-btn" type="button" title="More options">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="1"></circle>
<circle cx="12" cy="5" r="1"></circle>
<circle cx="12" cy="19" r="1"></circle>
</svg>
</button>
</div>
`;
}
createAlbumCardHTML(album) { createAlbumCardHTML(album) {
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : ''; const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
@ -130,18 +148,71 @@ export class UIRenderer {
} }
} }
renderHomePage() { async renderHomePage() {
this.showPage('home'); this.showPage('home');
const recents = recentActivityManager.getRecents(); const recents = recentActivityManager.getRecents();
document.getElementById('home-recent-albums').innerHTML = recents.albums.length const albumsContainer = document.getElementById('home-recent-albums');
const artistsContainer = document.getElementById('home-recent-artists');
if (recents.albums.length > 0 || recents.artists.length > 0) {
albumsContainer.innerHTML = recents.albums.length
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('') ? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder("You haven't viewed any albums yet."); : createPlaceholder("You haven't viewed any albums yet.");
document.getElementById('home-recent-artists').innerHTML = recents.artists.length artistsContainer.innerHTML = recents.artists.length
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('') ? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
: createPlaceholder("You haven't viewed any artists yet."); : createPlaceholder("You haven't viewed any artists yet.");
} else {
// Load from API
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
const homeData = await window.loadHomeFeed(this.api, this);
if (homeData && homeData.rows) {
let albums = [];
let playlists = [];
homeData.rows.forEach(row => {
row.modules?.forEach(module => {
if (module.type === 'ALBUM_LIST' && module.pagedList?.items) {
albums.push(...module.pagedList.items);
} else if (module.type === 'PLAYLIST_LIST' && module.pagedList?.items) {
playlists.push(...module.pagedList.items);
}
});
});
if (albums.length > 0) {
albumsContainer.innerHTML = albums.slice(0, 10).map(album =>
this.createAlbumCardHTML(album)
).join('');
} else {
albumsContainer.innerHTML = createPlaceholder("No albums available.");
}
if (playlists.length > 0) {
document.querySelector('#home-recent-artists').parentElement.querySelector('.section-title').textContent = 'Featured Playlists';
artistsContainer.innerHTML = playlists.slice(0, 10).map(playlist => `
<a href="#playlist/${playlist.uuid}" class="card">
<div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(playlist.image || playlist.squareImage, '320')}"
alt="${playlist.title}" class="card-image" loading="lazy">
</div>
<h3 class="card-title">${playlist.title}</h3>
<p class="card-subtitle">${playlist.numberOfTracks} tracks</p>
</a>
`).join('');
} else {
artistsContainer.innerHTML = createPlaceholder("No playlists available.");
}
} else {
albumsContainer.innerHTML = createPlaceholder("Unable to load content.");
artistsContainer.innerHTML = createPlaceholder("Unable to load content.");
}
} }
}
async renderSearchPage(query) { async renderSearchPage(query) {
this.showPage('search'); this.showPage('search');

View file

@ -1762,4 +1762,94 @@ input:checked + .slider:before {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
} }
.track-item {
display: grid;
grid-template-columns: 40px 1fr auto auto;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius);
cursor: pointer;
transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.track-menu-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
cursor: pointer;
padding: 0.5rem;
border-radius: var(--radius);
transition: all .2s;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
}
.track-item:hover .track-menu-btn {
opacity: 1;
}
.track-menu-btn:hover {
background-color: var(--secondary);
color: var(--foreground);
}
@media (max-width: 768px) {
.track-menu-btn {
opacity: 1;
}
}
.track-item {
display: grid;
grid-template-columns: 40px 1fr auto auto;
align-items: center;
gap: var(--spacing-md);
padding: var(--spacing-sm);
border-radius: var(--radius);
cursor: pointer;
transition: all .2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.track-menu-btn {
background: transparent;
border: none;
color: var(--muted-foreground);
cursor: pointer;
padding: 0.5rem;
border-radius: var(--radius);
transition: all .2s;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: all;
z-index: 10;
}
.track-item:hover .track-menu-btn {
opacity: 1;
}
.track-menu-btn:hover {
background-color: var(--secondary);
color: var(--foreground);
}
@media (max-width: 768px) {
.track-menu-btn {
opacity: 1;
}
}
@media (hover: none) {
.track-menu-btn {
opacity: 1;
}
}