Reworked settings page
Build and Publish / Build and Publish Docker Image (push) Successful in 3m28s
Build and Publish / Build and Publish Docker Image (push) Successful in 3m28s
This commit is contained in:
@@ -825,7 +825,7 @@
|
||||
@click.stop="$store.history.playFrom(idx)"
|
||||
:title="item.track?.title || item.track_title">
|
||||
<template x-if="item.track && item.track.cover_url">
|
||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||
<img :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!item.track || !item.track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
|
||||
+253
-21
@@ -2198,6 +2198,8 @@ document.addEventListener('alpine:init', () => {
|
||||
_dragOverIdx: null,
|
||||
_pointerDragMove: null,
|
||||
_pointerDragEnd: null,
|
||||
_playNextGroupId: null,
|
||||
_playNextGroupSequence: 0,
|
||||
|
||||
add(track) {
|
||||
this.addToEnd([track]);
|
||||
@@ -2210,13 +2212,21 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
effectiveCurrentIndex() {
|
||||
const currentTrack = Alpine.store('player')?.currentTrack || null;
|
||||
if (currentTrack?.id) {
|
||||
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
|
||||
const currentKey = this._trackIdentity(currentTrack);
|
||||
if (currentKey) {
|
||||
const index = this.tracks.findIndex(track => this._trackIdentity(track) === currentKey);
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
if (!this.tracks.length) return -1;
|
||||
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
|
||||
},
|
||||
|
||||
_trackIdentity(track) {
|
||||
if (track?.content_id) return `content:${track.content_id}`;
|
||||
if (track?.id != null && track.id !== '') return `id:${String(track.id)}`;
|
||||
return '';
|
||||
},
|
||||
|
||||
queueItemState(index) {
|
||||
const current = this.effectiveCurrentIndex();
|
||||
if (current < 0) return 'upcoming';
|
||||
@@ -2252,8 +2262,9 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
syncCurrentIndexToTrack(track) {
|
||||
if (!track?.id || !this.tracks.length) return -1;
|
||||
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
|
||||
const key = this._trackIdentity(track);
|
||||
if (!key || !this.tracks.length) return -1;
|
||||
const index = this.tracks.findIndex(item => this._trackIdentity(item) === key);
|
||||
if (index >= 0) this.currentIndex = index;
|
||||
return index;
|
||||
},
|
||||
@@ -2280,6 +2291,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
playRelease(tracks, startIndex) {
|
||||
this.tracks = this._tracksForQueueAdd(tracks);
|
||||
this._playNextGroupId = null;
|
||||
this.playFromIndex(startIndex || 0);
|
||||
},
|
||||
|
||||
@@ -2447,8 +2459,35 @@ document.addEventListener('alpine:init', () => {
|
||||
_addNextLocal(tracks) {
|
||||
const items = this._tracksWithJamDefaults(tracks);
|
||||
if (!items.length) return;
|
||||
const insertAt = Math.min(this.currentIndex + 1, this.tracks.length);
|
||||
this.tracks.splice(insertAt, 0, ...items);
|
||||
const current = this.effectiveCurrentIndex();
|
||||
let insertAt = Math.min(Math.max(0, current + 1), this.tracks.length);
|
||||
let groupId = this._playNextGroupId
|
||||
|| this.tracks[insertAt]?._playNextGroupId
|
||||
|| null;
|
||||
if (groupId) this._playNextGroupId = groupId;
|
||||
if (groupId) {
|
||||
let lastGroupIndex = -1;
|
||||
for (let index = current; index < this.tracks.length; index++) {
|
||||
if (this.tracks[index]?._playNextGroupId === groupId) {
|
||||
lastGroupIndex = index;
|
||||
}
|
||||
}
|
||||
if (lastGroupIndex >= current) {
|
||||
insertAt = lastGroupIndex + 1;
|
||||
} else {
|
||||
groupId = null;
|
||||
}
|
||||
}
|
||||
if (!groupId) {
|
||||
this._playNextGroupSequence += 1;
|
||||
groupId = `next-${Date.now()}-${this._playNextGroupSequence}`;
|
||||
this._playNextGroupId = groupId;
|
||||
}
|
||||
const groupedItems = items.map(item => ({
|
||||
...item,
|
||||
_playNextGroupId: groupId,
|
||||
}));
|
||||
this.tracks.splice(insertAt, 0, ...groupedItems);
|
||||
},
|
||||
|
||||
_removeLocal(idx) {
|
||||
@@ -2471,6 +2510,7 @@ document.addEventListener('alpine:init', () => {
|
||||
if (toIdx < 0 || toIdx >= this.tracks.length) return;
|
||||
const [track] = this.tracks.splice(fromIdx, 1);
|
||||
this.tracks.splice(toIdx, 0, track);
|
||||
this._playNextGroupId = null;
|
||||
// Adjust currentIndex to follow the currently playing track
|
||||
if (this.currentIndex === fromIdx) {
|
||||
this.currentIndex = toIdx;
|
||||
@@ -2484,6 +2524,7 @@ document.addEventListener('alpine:init', () => {
|
||||
_clearLocal() {
|
||||
this.tracks = [];
|
||||
this.currentIndex = 0;
|
||||
this._playNextGroupId = null;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2508,6 +2549,7 @@ document.addEventListener('alpine:init', () => {
|
||||
searchLoading: false,
|
||||
similaritySearchLabel: '',
|
||||
similaritySearchError: '',
|
||||
similaritySearchStats: { loading: false, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 },
|
||||
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
|
||||
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
|
||||
federationPreparing: {},
|
||||
@@ -3400,6 +3442,7 @@ document.addEventListener('alpine:init', () => {
|
||||
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
||||
if (!res.ok) throw new Error('failed');
|
||||
this.searchResults = await res.json();
|
||||
this.applyFederationArtworkFallbacks();
|
||||
} catch {
|
||||
this.searchResults = { artists: [], releases: [], tracks: [] };
|
||||
}
|
||||
@@ -3425,18 +3468,30 @@ document.addEventListener('alpine:init', () => {
|
||||
this.searchLoading = true;
|
||||
this.searchResults = null;
|
||||
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
|
||||
this.similaritySearchStats = { loading: true, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 };
|
||||
Alpine.store('info').close();
|
||||
try {
|
||||
const response = await fetch(`/api/player/similarity/${id}`);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || T.similarityFailed);
|
||||
this.similaritySearchLabel = data.label || initialLabel;
|
||||
const completeRequest = fetch(`/api/player/similarity/${id}`);
|
||||
const localResponse = await fetch(`/api/player/similarity/${id}?local_only=true`);
|
||||
const localData = await localResponse.json().catch(() => ({}));
|
||||
if (!localResponse.ok) throw new Error(localData.error || T.similarityFailed);
|
||||
this.similaritySearchLabel = localData.label || initialLabel;
|
||||
this.searchQuery = this.similaritySearchLabel;
|
||||
this.searchResults = {
|
||||
artists: [],
|
||||
releases: [],
|
||||
tracks: Array.isArray(data.tracks) ? data.tracks : [],
|
||||
tracks: Array.isArray(localData.tracks) ? localData.tracks : [],
|
||||
};
|
||||
this.searchLoading = false;
|
||||
this.similaritySearchStats = {
|
||||
loading: true,
|
||||
...this.similarityResultCounts(this.searchResults.tracks, []),
|
||||
peers: 0,
|
||||
elapsed_ms: localData.elapsed_ms || 0,
|
||||
};
|
||||
const response = await completeRequest;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data.error || T.similarityFailed);
|
||||
this.federationSearch = {
|
||||
loading: false,
|
||||
error: data.federation_error || '',
|
||||
@@ -3444,15 +3499,94 @@ document.addEventListener('alpine:init', () => {
|
||||
releases: [],
|
||||
tracks: Array.isArray(data.federation_tracks) ? data.federation_tracks : [],
|
||||
};
|
||||
this.similaritySearchStats = {
|
||||
loading: false,
|
||||
...this.similarityResultCounts(
|
||||
this.searchResults.tracks,
|
||||
this.federationSearch.tracks
|
||||
),
|
||||
peers: Number(data.queried_peers || 0),
|
||||
elapsed_ms: Number(data.elapsed_ms || 0),
|
||||
};
|
||||
} catch (error) {
|
||||
this.searchResults = { artists: [], releases: [], tracks: [] };
|
||||
this.federationSearch = { loading: false, error: '', artists: [], releases: [], tracks: [] };
|
||||
this.similaritySearchError = error?.message || T.similarityFailed;
|
||||
if (!this.searchResults) {
|
||||
this.searchResults = { artists: [], releases: [], tracks: [] };
|
||||
this.similaritySearchError = error?.message || T.similarityFailed;
|
||||
} else {
|
||||
this.federationSearch = {
|
||||
...this.federationSearch,
|
||||
loading: false,
|
||||
error: error?.message || T.similarityFailed,
|
||||
};
|
||||
}
|
||||
this.similaritySearchStats = {
|
||||
...this.similaritySearchStats,
|
||||
loading: false,
|
||||
};
|
||||
}
|
||||
this.searchLoading = false;
|
||||
this._afterNavigation(options);
|
||||
},
|
||||
|
||||
similarityResultCounts(localTracks = [], federationTracks = []) {
|
||||
const artists = new Set();
|
||||
for (const track of localTracks) {
|
||||
for (const artist of [...(track?.artists || []), ...(track?.featured_artists || [])]) {
|
||||
const name = this.normalizeFederationSearchText(artist?.name);
|
||||
if (name) artists.add(name);
|
||||
}
|
||||
}
|
||||
for (const track of federationTracks) {
|
||||
const metadata = track?.metadata || {};
|
||||
for (const artist of [...(metadata.artists || []), ...(metadata.featured_artists || [])]) {
|
||||
const name = this.normalizeFederationSearchText(artist?.name);
|
||||
if (name) artists.add(name);
|
||||
}
|
||||
}
|
||||
return {
|
||||
tracks: localTracks.length + federationTracks.length,
|
||||
artists: artists.size,
|
||||
};
|
||||
},
|
||||
|
||||
similarityTrackOrder(track) {
|
||||
const score = Number(track?.similarity_score);
|
||||
if (!Number.isFinite(score)) return 1000000;
|
||||
return Math.max(0, Math.round((1 - score) * 100000));
|
||||
},
|
||||
|
||||
similarityQueueTracks() {
|
||||
const local = (this.searchResults?.tracks || []).map(track => ({ ...track }));
|
||||
const federated = (this.federationSearch?.tracks || []).map(track => ({
|
||||
...this.federationQueueTrack(track),
|
||||
similarity_score: track.similarity_score,
|
||||
}));
|
||||
return [...local, ...federated].sort((left, right) => {
|
||||
const score = Number(right?.similarity_score || 0)
|
||||
- Number(left?.similarity_score || 0);
|
||||
if (score) return score;
|
||||
return String(left?.title || '').localeCompare(String(right?.title || ''));
|
||||
});
|
||||
},
|
||||
|
||||
playSimilarityResult(track) {
|
||||
const queue = Alpine.store('queue');
|
||||
const tracks = this.similarityQueueTracks();
|
||||
const key = queue._trackIdentity(track);
|
||||
const index = tracks.findIndex(item => queue._trackIdentity(item) === key);
|
||||
if (index >= 0) queue.playRelease(tracks, index);
|
||||
},
|
||||
|
||||
formatSearchDuration(milliseconds) {
|
||||
const ms = Math.max(0, Number(milliseconds) || 0);
|
||||
if (ms < 10000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
if (ms < 60000) return `${Math.round(ms / 1000)} s`;
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
||||
},
|
||||
|
||||
clearSearch() {
|
||||
this.stopFederationSearch();
|
||||
this.searchQuery = '';
|
||||
@@ -3460,6 +3594,7 @@ document.addEventListener('alpine:init', () => {
|
||||
this.searchLoading = false;
|
||||
this.similaritySearchLabel = '';
|
||||
this.similaritySearchError = '';
|
||||
this.similaritySearchStats = { loading: false, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 };
|
||||
if (this.view === 'search') {
|
||||
this.view = this._previousView || 'artists';
|
||||
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
||||
@@ -3518,13 +3653,17 @@ document.addEventListener('alpine:init', () => {
|
||||
};
|
||||
source.addEventListener('federation.track', upsertTrack);
|
||||
source.addEventListener('federation.artist', event => {
|
||||
const artist = JSON.parse(event.data)?.entity;
|
||||
const artist = this.withFederationArtistFallback(
|
||||
JSON.parse(event.data)?.entity
|
||||
);
|
||||
const key = artist?.key?.normalized_name;
|
||||
if (!key) return;
|
||||
updateResults('artists', item => item.key.normalized_name, item => item.name, artist);
|
||||
});
|
||||
source.addEventListener('federation.release', event => {
|
||||
const release = JSON.parse(event.data)?.entity;
|
||||
const release = this.hydrateFederationSearchRelease(
|
||||
JSON.parse(event.data)?.entity
|
||||
);
|
||||
if (!release?.key) return;
|
||||
updateResults('releases', item => JSON.stringify(item.key || {}), item => item.title, release);
|
||||
});
|
||||
@@ -3601,9 +3740,98 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
federationArtistImage(artist) {
|
||||
if (!artist?.name) return '';
|
||||
if (artist._federationArtworkFailed) return artist.local_image_url || '';
|
||||
return this.federationDiscoveredArtwork(artist.name);
|
||||
},
|
||||
|
||||
localArtistImage(name) {
|
||||
const key = this.normalizeFederationSearchText(name);
|
||||
return (this.searchResults?.artists || []).find(candidate =>
|
||||
this.normalizeFederationSearchText(candidate.name) === key
|
||||
)?.image_url || '';
|
||||
},
|
||||
|
||||
withFederationArtistFallback(artist) {
|
||||
if (!artist) return artist;
|
||||
return { ...artist, local_image_url: this.localArtistImage(artist.name) };
|
||||
},
|
||||
|
||||
applyFederationArtworkFallbacks() {
|
||||
this.federationSearch = {
|
||||
...this.federationSearch,
|
||||
artists: this.federationSearch.artists.map(artist =>
|
||||
this.withFederationArtistFallback(artist)
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
federationArtistImageFailed(artist) {
|
||||
const key = artist?.key?.normalized_name;
|
||||
if (!key) return;
|
||||
this.federationSearch = {
|
||||
...this.federationSearch,
|
||||
artists: this.federationSearch.artists.map(candidate =>
|
||||
candidate?.key?.normalized_name === key
|
||||
? {
|
||||
...candidate,
|
||||
_federationArtworkFailed: true,
|
||||
local_image_url: candidate.local_image_url
|
||||
|| this.localArtistImage(candidate.name),
|
||||
}
|
||||
: candidate
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
hydrateFederationSearchRelease(release) {
|
||||
if (!release) return release;
|
||||
const title = this.normalizeFederationSearchText(release.title);
|
||||
const primaryArtists = (release.key?.primary_artists || [])
|
||||
.map(name => this.normalizeFederationSearchText(name));
|
||||
const tracks = this.federationSearch.tracks.filter(track => {
|
||||
const metadata = track?.metadata || {};
|
||||
if (this.normalizeFederationSearchText(metadata.release?.title) !== title) return false;
|
||||
if (release.year && metadata.year && Number(release.year) !== Number(metadata.year)) return false;
|
||||
if (!primaryArtists.length) return true;
|
||||
const trackArtists = (metadata.artists || []).map(artist =>
|
||||
this.normalizeFederationSearchText(artist.name)
|
||||
);
|
||||
return primaryArtists.some(artist => trackArtists.includes(artist));
|
||||
});
|
||||
const owners = [...new Set([
|
||||
...(release.sources || []).map(source => source.owner),
|
||||
...tracks.flatMap(track =>
|
||||
(track.availability?.federation || []).map(source => source.owner)
|
||||
),
|
||||
].filter(Boolean))];
|
||||
return { ...release, tracks, owners };
|
||||
},
|
||||
|
||||
federationReleaseCover(release) {
|
||||
if (!release) return '';
|
||||
if (release._federationArtworkFailed) return release._discoveredCoverUrl || '';
|
||||
return release.cover_url
|
||||
|| this.federationDiscoveredArtwork(release.artists?.[0], release.title);
|
||||
},
|
||||
|
||||
federationReleaseCoverFailed(release, failedUrl) {
|
||||
const discovered = this.federationDiscoveredArtwork(release?.artists?.[0], release?.title);
|
||||
if (!release?.key) return;
|
||||
const key = JSON.stringify(release.key);
|
||||
this.federationSearch = {
|
||||
...this.federationSearch,
|
||||
releases: this.federationSearch.releases.map(candidate =>
|
||||
JSON.stringify(candidate?.key) === key
|
||||
? {
|
||||
...candidate,
|
||||
_federationArtworkFailed: true,
|
||||
_discoveredCoverUrl: failedUrl === discovered ? '' : discovered,
|
||||
}
|
||||
: candidate
|
||||
),
|
||||
};
|
||||
},
|
||||
|
||||
federationDiscoveredArtwork(artist, release = '') {
|
||||
if (!artist) return '';
|
||||
const params = new URLSearchParams({ artist });
|
||||
@@ -3696,22 +3924,26 @@ document.addEventListener('alpine:init', () => {
|
||||
uploader_name: 'Federation',
|
||||
federation_pending: true,
|
||||
_federationTrack: track,
|
||||
similarity_score: track.similarity_score,
|
||||
};
|
||||
},
|
||||
|
||||
openFederatedRelease(release, options = {}) {
|
||||
if (!release?.key) return;
|
||||
this._federatedReleaseCache[release.key] = release;
|
||||
this._beginNavigation('#releasefed?key=' + encodeURIComponent(release.key), options);
|
||||
const cacheKey = typeof release.key === 'string'
|
||||
? release.key
|
||||
: JSON.stringify(release.key);
|
||||
this._federatedReleaseCache[cacheKey] = release;
|
||||
this._beginNavigation('#releasefed?key=' + encodeURIComponent(cacheKey), options);
|
||||
const queuedTracks = (release.tracks || []).map(track => this.federationQueueTrack(track));
|
||||
const first = queuedTracks[0];
|
||||
this.currentRelease = {
|
||||
id: null,
|
||||
title: release.title,
|
||||
release_type: release.release_type || 'release',
|
||||
release_type: release.release_type || release.key?.release_type || 'release',
|
||||
year: release.year,
|
||||
cover_url: release.cover_url,
|
||||
artists: first?.artists || [],
|
||||
cover_url: this.federationReleaseCover(release),
|
||||
artists: first?.artists || (release.artists || []).map(name => ({ id: null, name })),
|
||||
tracks: queuedTracks,
|
||||
uploaders: (release.owners || []).map(owner => ({
|
||||
name: `Federation ${owner.slice(0, 10)}`,
|
||||
|
||||
+89
-52
@@ -17,6 +17,17 @@
|
||||
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
||||
</div>
|
||||
<div class="user-widget-actions">
|
||||
<button class="user-logout-btn"
|
||||
x-show="$store.user.profile?.role === 'admin'"
|
||||
x-cloak
|
||||
@click="window.location.href = '/admin/'"
|
||||
title="{{ t.player_admin_panel }}"
|
||||
aria-label="{{ t.player_admin_panel }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M12 3l7 3v5c0 4.6-2.9 8.1-7 10-4.1-1.9-7-5.4-7-10V6l7-3z"/>
|
||||
<path d="M9.5 12l1.7 1.7 3.6-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="user-logout-btn" @click="$store.user.openSettings()" title="User settings" aria-label="User settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
@@ -80,7 +91,7 @@
|
||||
@click="$store.library.openArtist(artist.id)">
|
||||
<div class="following-avatar">
|
||||
<template x-if="artist.image_url">
|
||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!artist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
@@ -143,9 +154,6 @@
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="sidebar-bottom">
|
||||
<a href="/admin/">{{ t.player_admin_panel }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template x-if="$store.mobile.libraryOpen">
|
||||
@@ -196,7 +204,7 @@
|
||||
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
|
||||
<div class="following-avatar">
|
||||
<template x-if="artist.image_url">
|
||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!artist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
@@ -359,6 +367,17 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-account-actions">
|
||||
<button class="user-logout-btn"
|
||||
x-show="$store.user.profile?.role === 'admin'"
|
||||
x-cloak
|
||||
@click="window.location.href = '/admin/'"
|
||||
title="{{ t.player_admin_panel }}"
|
||||
aria-label="{{ t.player_admin_panel }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<path d="M12 3l7 3v5c0 4.6-2.9 8.1-7 10-4.1-1.9-7-5.4-7-10V6l7-3z"/>
|
||||
<path d="M9.5 12l1.7 1.7 3.6-4"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="user-logout-btn"
|
||||
@click="$store.user.menuOpen = false; $store.user.openSettings()"
|
||||
title="User settings"
|
||||
@@ -379,12 +398,24 @@
|
||||
<!-- Search Results -->
|
||||
<template x-if="$store.library.view === 'search'">
|
||||
<div>
|
||||
<h2 class="search-similarity-title"
|
||||
x-show="$store.library.similaritySearchLabel"
|
||||
x-cloak>
|
||||
<span>{{ t.player_search_similar_to }}</span>
|
||||
<strong x-text="$store.library.similaritySearchLabel"></strong>
|
||||
</h2>
|
||||
<div class="search-similarity-heading"
|
||||
x-show="$store.library.similaritySearchLabel"
|
||||
x-cloak>
|
||||
<h2 class="search-similarity-title">
|
||||
<span>{{ t.player_search_similar_to }}</span>
|
||||
<strong x-text="$store.library.similaritySearchLabel"></strong>
|
||||
</h2>
|
||||
<div class="search-similarity-progress"
|
||||
:class="{ loading: $store.library.similaritySearchStats.loading }">
|
||||
<template x-if="$store.library.similaritySearchStats.loading">
|
||||
<span class="search-progress-live"><i></i> Searching peers…</span>
|
||||
</template>
|
||||
<span x-text="`${$store.library.similaritySearchStats.tracks} tracks · ${$store.library.similaritySearchStats.artists} artists`"></span>
|
||||
<template x-if="!$store.library.similaritySearchStats.loading">
|
||||
<span x-text="`${$store.library.similaritySearchStats.peers} peers · ${$store.library.formatSearchDuration($store.library.similaritySearchStats.elapsed_ms)}`"></span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="$store.library.searchLoading">
|
||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||
</template>
|
||||
@@ -394,7 +425,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
||||
<div>
|
||||
<div :class="{ 'similarity-unified-results': $store.library.similaritySearchLabel }">
|
||||
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
@@ -409,11 +440,9 @@
|
||||
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
|
||||
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
|
||||
<div class="search-artist-img">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
<template x-if="artist.image_url">
|
||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!artist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
<img class="artwork-image" :src="artist.image_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||
</template>
|
||||
</div>
|
||||
<div class="search-artist-name" x-text="artist.name"></div>
|
||||
@@ -430,11 +459,9 @@
|
||||
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
|
||||
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
|
||||
<div class="search-release-cover" style="position:relative">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
<template x-if="release.cover_url">
|
||||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!release.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
<img class="artwork-image" :src="release.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||
</template>
|
||||
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
|
||||
@@ -464,7 +491,8 @@
|
||||
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
|
||||
<div class="track-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.library.playSearchTrack(idx)">
|
||||
:style="$store.library.similaritySearchLabel ? `order:${$store.library.similarityTrackOrder(track)}` : ''"
|
||||
@dblclick="$store.library.similaritySearchLabel ? $store.library.playSimilarityResult(track) : $store.library.playSearchTrack(idx)">
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="track.title"></div>
|
||||
@@ -509,8 +537,9 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="search-section federation-search-section">
|
||||
<h2 class="search-section-title">
|
||||
<div class="search-section federation-search-section"
|
||||
:class="{ 'similarity-federation-merged': $store.library.similaritySearchLabel }">
|
||||
<h2 class="search-section-title" x-show="!$store.library.similaritySearchLabel">
|
||||
Federation
|
||||
<span class="federation-live-badge"
|
||||
x-show="$store.library.federationSearch.loading"
|
||||
@@ -520,11 +549,11 @@
|
||||
<div class="federation-search-status error"
|
||||
x-text="$store.library.federationSearch.error"></div>
|
||||
</template>
|
||||
<template x-if="$store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
|
||||
<template x-if="!$store.library.similaritySearchLabel && $store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
|
||||
<div class="federation-search-status">Searching peers…</div>
|
||||
</template>
|
||||
<div class="search-artists-row"
|
||||
x-show="$store.library.federationSearch.artists.length > 0"
|
||||
x-show="!$store.library.similaritySearchLabel && $store.library.federationSearch.artists.length > 0"
|
||||
x-cloak>
|
||||
<template x-for="artist in $store.library.federationSearch.artists"
|
||||
:key="artist.key.normalized_name">
|
||||
@@ -532,10 +561,12 @@
|
||||
@click="$store.library.openFederatedArtist(artist)">
|
||||
<div class="search-artist-img">
|
||||
<img x-show="$store.library.federationArtistImage(artist)"
|
||||
class="artwork-image"
|
||||
:src="$store.library.federationArtistImage(artist)"
|
||||
:alt="artist.name"
|
||||
alt="" aria-hidden="true"
|
||||
loading="lazy"
|
||||
@error="$event.currentTarget.style.display = 'none'">
|
||||
@load="$event.currentTarget.classList.add('artwork-loaded')"
|
||||
@error="$event.currentTarget.classList.remove('artwork-loaded'); $store.library.federationArtistImageFailed(artist)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/>
|
||||
</svg>
|
||||
@@ -547,18 +578,21 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="search-releases-row"
|
||||
x-show="$store.library.federationSearch.releases.length > 0"
|
||||
x-show="!$store.library.similaritySearchLabel && $store.library.federationSearch.releases.length > 0"
|
||||
x-cloak>
|
||||
<template x-for="release in $store.library.federationSearch.releases"
|
||||
:key="JSON.stringify(release.key)">
|
||||
<div class="search-release-card federation-entity-card">
|
||||
<div class="search-release-card federation-entity-card"
|
||||
@click="$store.library.openFederatedRelease($store.library.hydrateFederationSearchRelease(release))">
|
||||
<div class="search-release-cover">
|
||||
<img x-show="release.cover_url"
|
||||
:src="release.cover_url"
|
||||
:alt="release.title"
|
||||
loading="lazy">
|
||||
<svg x-show="!release.cover_url"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<img x-show="$store.library.federationReleaseCover(release)"
|
||||
class="artwork-image"
|
||||
:src="$store.library.federationReleaseCover(release)"
|
||||
alt="" aria-hidden="true"
|
||||
loading="lazy"
|
||||
@load="$event.currentTarget.classList.add('artwork-loaded')"
|
||||
@error="$event.currentTarget.classList.remove('artwork-loaded'); $store.library.federationReleaseCoverFailed(release, $event.currentTarget.getAttribute('src'))">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -571,7 +605,8 @@
|
||||
<template x-for="(track, idx) in $store.library.federationSearch.tracks"
|
||||
:key="track.key.content_id">
|
||||
<div class="track-row federation-track-row"
|
||||
@dblclick="$store.library.playFederatedTrack(track)">
|
||||
:style="$store.library.similaritySearchLabel ? `order:${$store.library.similarityTrackOrder(track)}` : ''"
|
||||
@dblclick="$store.library.similaritySearchLabel ? $store.library.playSimilarityResult($store.library.federationQueueTrack(track)) : $store.library.playFederatedTrack(track)">
|
||||
<span class="track-num federation-track-status">
|
||||
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
@@ -645,6 +680,12 @@
|
||||
x-text="formatTime(track.metadata.duration_seconds)"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="similarity-search-inline-progress"
|
||||
x-show="$store.library.similaritySearchLabel && $store.library.similaritySearchStats.loading"
|
||||
x-cloak>
|
||||
<span class="similarity-search-inline-spinner" aria-hidden="true"></span>
|
||||
<span>Searching federation for more similar tracks…</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -666,7 +707,7 @@
|
||||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||
<div class="card-img">
|
||||
<template x-if="artist.image_url">
|
||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!artist.image_url">
|
||||
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
|
||||
@@ -722,7 +763,7 @@
|
||||
<div class="artist-img">
|
||||
<template x-if="$store.library.currentArtist.image_url">
|
||||
<img :src="$store.library.currentArtist.image_url"
|
||||
:alt="$store.library.currentArtist.name"
|
||||
alt="" aria-hidden="true"
|
||||
@error="$store.library.currentArtist.image_url = null">
|
||||
</template>
|
||||
<template x-if="!$store.library.currentArtist.image_url">
|
||||
@@ -805,7 +846,7 @@
|
||||
:title="track.release_title"
|
||||
aria-label="{{ t.player_release }}">
|
||||
<template x-if="track.cover_url">
|
||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
||||
<img :src="track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
@@ -868,7 +909,7 @@
|
||||
<div class="card" @click="$store.library.openRelease(release.id)">
|
||||
<div class="card-img">
|
||||
<template x-if="release.cover_url">
|
||||
<img :src="release.cover_url" :alt="release.title"
|
||||
<img :src="release.cover_url" alt="" aria-hidden="true"
|
||||
loading="lazy" @error="release.cover_url = null">
|
||||
</template>
|
||||
<template x-if="!release.cover_url">
|
||||
@@ -904,7 +945,7 @@
|
||||
@click="$store.library.openFederatedRelease(release)">
|
||||
<div class="card-img">
|
||||
<template x-if="release.cover_url">
|
||||
<img :src="release.cover_url" :alt="release.title"
|
||||
<img :src="release.cover_url" alt="" aria-hidden="true"
|
||||
loading="lazy" @error="release.cover_url = null">
|
||||
</template>
|
||||
<template x-if="!release.cover_url">
|
||||
@@ -1034,7 +1075,7 @@
|
||||
:title="track.release_title"
|
||||
aria-label="{{ t.player_release }}">
|
||||
<template x-if="track.cover_url">
|
||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
||||
<img :src="track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
@@ -1108,7 +1149,7 @@
|
||||
<div class="release-header">
|
||||
<div class="release-cover">
|
||||
<template x-if="$store.library.currentRelease.cover_url">
|
||||
<img :src="$store.library.currentRelease.cover_url" :alt="$store.library.currentRelease.title">
|
||||
<img :src="$store.library.currentRelease.cover_url" alt="" aria-hidden="true">
|
||||
</template>
|
||||
<template x-if="!$store.library.currentRelease.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
@@ -1486,11 +1527,9 @@
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||||
</div>
|
||||
<div class="queue-track-cover">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<template x-if="item.track.cover_url">
|
||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!item.track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<img class="artwork-image" :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||
</template>
|
||||
<span class="queue-federation-status federation-track-status"
|
||||
x-show="item.track.federation_pending"
|
||||
@@ -1570,7 +1609,7 @@
|
||||
<div class="player-cover"
|
||||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||||
<template x-if="$store.player.currentTrack.cover_url">
|
||||
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
|
||||
<img :src="$store.player.currentTrack.cover_url" alt="" aria-hidden="true">
|
||||
</template>
|
||||
<template x-if="!$store.player.currentTrack.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
@@ -1868,11 +1907,9 @@
|
||||
type="button"
|
||||
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
|
||||
<div class="mobile-expanded-queue-cover">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<template x-if="item.track.cover_url">
|
||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!item.track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<img class="artwork-image" :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
|
||||
</template>
|
||||
</div>
|
||||
<div class="mobile-expanded-queue-info">
|
||||
|
||||
@@ -450,22 +450,6 @@ button.user-stat:hover {
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-bottom a {
|
||||
color: var(--text-subdued);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sidebar-bottom a:hover { color: var(--text-secondary); }
|
||||
|
||||
/* Center Content */
|
||||
.center-content {
|
||||
flex: 1;
|
||||
@@ -1411,7 +1395,7 @@ button.user-stat:hover {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.queue-track-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
||||
|
||||
.queue-track-cover .queue-federation-status {
|
||||
@@ -2751,6 +2735,41 @@ button.user-stat:hover {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 18px;
|
||||
}
|
||||
.federation-search-section.similarity-federation-merged {
|
||||
margin-top: -24px;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.similarity-unified-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.similarity-unified-results > .search-section {
|
||||
display: contents;
|
||||
}
|
||||
.similarity-unified-results .search-section-title { order: -1000002; }
|
||||
.similarity-unified-results .track-list-header { order: -1000001; }
|
||||
.similarity-unified-results .federation-search-status { order: 1000001; }
|
||||
.similarity-search-inline-progress {
|
||||
order: 1000000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-height: 42px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.similarity-search-inline-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, .16);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 999px;
|
||||
animation: federation-progress-spin .8s linear infinite;
|
||||
}
|
||||
.federation-live-badge {
|
||||
margin-left: 8px;
|
||||
color: var(--accent);
|
||||
@@ -2872,12 +2891,20 @@ button.user-stat:hover {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-similarity-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.search-similarity-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 20px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
@@ -2886,6 +2913,42 @@ button.user-stat:hover {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
}
|
||||
.search-similarity-progress {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 30px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-similarity-progress.loading { border-color: rgba(29, 185, 84, .38); }
|
||||
.search-progress-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.search-progress-live i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
animation: similarity-search-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes similarity-search-pulse {
|
||||
50% { opacity: .3; transform: scale(.75); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.search-similarity-heading { align-items: flex-start; flex-direction: column; }
|
||||
.search-similarity-progress { max-width: 100%; flex-wrap: wrap; white-space: normal; }
|
||||
}
|
||||
|
||||
.search-artists-row {
|
||||
display: flex;
|
||||
@@ -2959,6 +3022,7 @@ button.user-stat:hover {
|
||||
.search-release-card:hover { background: var(--bg-elevated); }
|
||||
|
||||
.search-release-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
@@ -2970,9 +3034,12 @@ button.user-stat:hover {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-release-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.search-release-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.search-release-cover svg { width: 40px; height: 40px; color: var(--text-subdued); }
|
||||
|
||||
.artwork-image { z-index: 1; opacity: 0; }
|
||||
.artwork-image.artwork-loaded { opacity: 1; }
|
||||
|
||||
/* Like button */
|
||||
.like-btn {
|
||||
background: none;
|
||||
@@ -5585,6 +5652,7 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.mobile-expanded-queue-cover {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 5px;
|
||||
@@ -5596,6 +5664,8 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.mobile-expanded-queue-cover img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
|
||||
Reference in New Issue
Block a user