c
This commit is contained in:
parent
5be3311133
commit
0c9dec35ff
7 changed files with 417 additions and 182 deletions
25
js/api.js
25
js/api.js
|
|
@ -309,19 +309,26 @@ 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 primaryData = await primaryResponse.json();
|
||||||
const artist = this.prepareArtist(Array.isArray(primaryData) ? primaryData[0] : primaryData);
|
const rawArtist = Array.isArray(primaryData) ? primaryData[0] : primaryData;
|
||||||
|
|
||||||
if (!artist) throw new Error('Primary artist details not found.');
|
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 contentData = await contentResponse.json();
|
||||||
const entries = Array.isArray(contentData) ? contentData : [contentData];
|
const entries = Array.isArray(contentData) ? contentData : [contentData];
|
||||||
|
|
@ -330,7 +337,7 @@ export class LosslessAPI {
|
||||||
const trackMap = new Map();
|
const trackMap = new Map();
|
||||||
|
|
||||||
const isTrack = v => v?.id && v.duration && v.album;
|
const isTrack = v => v?.id && v.duration && v.album;
|
||||||
const isAlbum = v => v?.id && v.cover && 'numberOfTracks' in v;
|
const isAlbum = v => v?.id && 'numberOfTracks' in v;
|
||||||
|
|
||||||
const scan = (value, visited = new Set()) => {
|
const scan = (value, visited = new Set()) => {
|
||||||
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
if (!value || typeof value !== 'object' || visited.has(value)) return;
|
||||||
|
|
@ -360,9 +367,9 @@ export class LosslessAPI {
|
||||||
|
|
||||||
const result = { ...artist, albums, tracks };
|
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}`;
|
||||||
|
|
|
||||||
57
js/app.js
57
js/app.js
|
|
@ -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,7 +413,7 @@ 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();
|
||||||
|
|
@ -406,7 +422,6 @@ lastfmConnectBtn?.addEventListener('click', async () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const authWindow = window.open('', '_blank');
|
const authWindow = window.open('', '_blank');
|
||||||
|
|
||||||
lastfmConnectBtn.disabled = true;
|
lastfmConnectBtn.disabled = true;
|
||||||
|
|
@ -464,8 +479,7 @@ lastfmConnectBtn?.addEventListener('click', async () => {
|
||||||
lastfmConnectBtn.disabled = false;
|
lastfmConnectBtn.disabled = false;
|
||||||
if (authWindow && !authWindow.closed) authWindow.close();
|
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');
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
//lastfm.js
|
||||||
import { delay } from './utils.js';
|
import { delay } from './utils.js';
|
||||||
|
|
||||||
export class LastFMScrobbler {
|
export class LastFMScrobbler {
|
||||||
|
|
|
||||||
32
js/player.js
32
js/player.js
|
|
@ -119,7 +119,7 @@ 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;
|
||||||
|
|
@ -143,7 +143,20 @@ export class Player {
|
||||||
if (this.preloadCache.has(track.id)) {
|
if (this.preloadCache.has(track.id)) {
|
||||||
streamUrl = this.preloadCache.get(track.id);
|
streamUrl = this.preloadCache.get(track.id);
|
||||||
} else {
|
} else {
|
||||||
streamUrl = await this.api.getStreamUrl(track.id, this.quality);
|
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) {
|
if (this.isCrossfading && this.nextAudioElement.src === streamUrl) {
|
||||||
|
|
@ -157,6 +170,9 @@ export class Player {
|
||||||
this.audio.src = streamUrl;
|
this.audio.src = streamUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply normalization if enabled
|
||||||
|
this.applyNormalization();
|
||||||
|
|
||||||
await this.audio.play();
|
await this.audio.play();
|
||||||
this.isCrossfading = false;
|
this.isCrossfading = false;
|
||||||
|
|
||||||
|
|
@ -169,7 +185,7 @@ export class Player {
|
||||||
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
|
document.querySelector('.now-playing-bar .title').textContent = `Error: ${track.title}`;
|
||||||
document.querySelector('.now-playing-bar .artist').textContent = error.message || 'Could not load track';
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
79
js/ui.js
79
js/ui.js
|
|
@ -11,6 +11,17 @@ 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>`;
|
||||||
|
|
@ -30,9 +41,16 @@ export class UIRenderer {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="track-item-duration">${formatTime(track.duration)}</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>
|
</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');
|
||||||
|
|
|
||||||
90
styles.css
90
styles.css
|
|
@ -1763,3 +1763,93 @@ input:checked + .slider:before {
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue