2025-10-18 14:35:19 +02:00
|
|
|
|
<template>
|
|
|
|
|
|
<div class="audio-cache-manager">
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<h2>🎵 Audio Cache Manager</h2>
|
|
|
|
|
|
<div class="stats">
|
|
|
|
|
|
<span>{{ tracks.length }} tracks</span>
|
2025-12-15 12:18:01 +01:00
|
|
|
|
<span v-if="lazyTracksCount > 0">{{ lazyTracksCount }} lazy pending</span>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
<span v-if="totalHits > 0">{{ totalHits }} hits</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Formulaire d'ajout -->
|
|
|
|
|
|
<div class="add-form">
|
|
|
|
|
|
<h3>➕ Add New Track</h3>
|
|
|
|
|
|
<form @submit.prevent="handleAddTrack">
|
|
|
|
|
|
<div class="form-group">
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="newTrackUrl"
|
|
|
|
|
|
type="url"
|
|
|
|
|
|
placeholder="https://example.com/track.flac"
|
|
|
|
|
|
required
|
|
|
|
|
|
:disabled="isAdding"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="newTrackCollection"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="Collection (optional)"
|
|
|
|
|
|
:disabled="isAdding"
|
|
|
|
|
|
class="collection-input"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<button type="submit" :disabled="isAdding || !newTrackUrl">
|
|
|
|
|
|
{{ isAdding ? "Adding..." : "Add Track" }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p v-if="addError" class="error">{{ addError }}</p>
|
|
|
|
|
|
<p v-if="addSuccess" class="success">{{ addSuccess }}</p>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Contrôles -->
|
|
|
|
|
|
<div class="controls">
|
|
|
|
|
|
<div class="sort-controls">
|
|
|
|
|
|
<label>Sort by:</label>
|
|
|
|
|
|
<select v-model="sortBy">
|
|
|
|
|
|
<option value="hits">Most Used</option>
|
|
|
|
|
|
<option value="last_used">Recently Used</option>
|
|
|
|
|
|
<option value="recent">Recently Added</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="actions">
|
|
|
|
|
|
<button @click="refreshTracks" :disabled="isLoading">
|
|
|
|
|
|
{{ isLoading ? "Loading..." : "Refresh" }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button @click="handleConsolidate" :disabled="isConsolidating" class="btn-secondary">
|
|
|
|
|
|
{{ isConsolidating ? "Consolidating..." : "Consolidate" }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button @click="handlePurge" class="btn-danger" :disabled="isPurging">
|
|
|
|
|
|
{{ isPurging ? "Purging..." : "Purge All" }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Liste des pistes -->
|
|
|
|
|
|
<div v-if="isLoading && tracks.length === 0" class="loading-state">
|
|
|
|
|
|
Loading tracks...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else-if="tracks.length === 0" class="empty-state">
|
|
|
|
|
|
No tracks in cache. Add one using the form above!
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div v-else class="track-grid">
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-for="track in sortedTracks"
|
|
|
|
|
|
:key="track.pk"
|
|
|
|
|
|
class="track-card"
|
|
|
|
|
|
@click="selectedTrack = track"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="track-icon">
|
2025-11-25 08:24:08 +01:00
|
|
|
|
<img
|
|
|
|
|
|
v-if="getTrackCoverUrl(track, 400)"
|
|
|
|
|
|
:src="getTrackCoverUrl(track, 400)"
|
|
|
|
|
|
:alt="`Cover for ${track.metadata?.title || 'Unknown'}`"
|
|
|
|
|
|
class="cover-image"
|
|
|
|
|
|
@error="handleCoverError(track.pk)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div v-else class="music-icon">🎵</div>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
<div class="track-overlay">
|
2025-12-15 12:18:01 +01:00
|
|
|
|
<span class="hits" v-if="!isLazyTrack(track)">{{ track.hits }} plays</span>
|
2025-12-15 15:05:35 +01:00
|
|
|
|
<span
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="hits lazy"
|
|
|
|
|
|
:class="lazyProviderClass(track)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ lazyBadgeLabel(track) }}
|
|
|
|
|
|
</span>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="track-info">
|
|
|
|
|
|
<div class="track-title">
|
|
|
|
|
|
{{ track.metadata?.title || "Unknown Title" }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="track-artist">
|
|
|
|
|
|
{{ track.metadata?.artist || "Unknown Artist" }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="track-album" v-if="track.metadata?.album">
|
|
|
|
|
|
{{ track.metadata.album }}
|
|
|
|
|
|
</div>
|
2025-12-15 12:18:01 +01:00
|
|
|
|
<div class="pk">
|
|
|
|
|
|
{{ track.pk }}
|
2025-12-15 15:05:35 +01:00
|
|
|
|
<span
|
|
|
|
|
|
v-if="isLazyTrack(track)"
|
|
|
|
|
|
class="lazy-tag"
|
|
|
|
|
|
:class="lazyProviderClass(track)"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="lazy-label">Lazy</span>
|
|
|
|
|
|
<span class="lazy-provider-name" v-if="lazyProviderName(track)">
|
|
|
|
|
|
{{ lazyProviderName(track) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</span>
|
2025-12-15 12:18:01 +01:00
|
|
|
|
</div>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
<div class="meta">
|
2025-10-26 18:30:50 +01:00
|
|
|
|
<span v-if="durationMs(track) !== undefined">
|
|
|
|
|
|
{{ formatDuration(durationMs(track)!) }}
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</span>
|
|
|
|
|
|
<span v-if="track.metadata?.sample_rate">
|
|
|
|
|
|
{{ formatSampleRate(track.metadata.sample_rate) }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span v-if="track.metadata?.bitrate">
|
|
|
|
|
|
{{ formatBitrate(track.metadata.bitrate) }}
|
|
|
|
|
|
</span>
|
2025-10-28 00:10:51 +01:00
|
|
|
|
<span v-if="conversionLabel(track)">
|
|
|
|
|
|
{{ conversionLabel(track) }}
|
|
|
|
|
|
</span>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="collection" v-if="track.collection">
|
|
|
|
|
|
{{ track.collection }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="last-used" v-if="track.last_used">
|
|
|
|
|
|
Last used: {{ formatDate(track.last_used) }}
|
|
|
|
|
|
</div>
|
2025-12-15 12:18:01 +01:00
|
|
|
|
<div class="lazy-warning" v-if="isLazyTrack(track)">
|
2025-12-15 15:05:35 +01:00
|
|
|
|
Audio not downloaded yet
|
|
|
|
|
|
<span v-if="lazyProviderName(track)">({{ lazyProviderName(track) }} provider)</span>.
|
|
|
|
|
|
First playback (or forcing download) will fetch it automatically.
|
2025-12-15 12:18:01 +01:00
|
|
|
|
</div>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="track-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
@click.stop="playTrack(track.pk)"
|
|
|
|
|
|
class="btn-play"
|
2025-12-15 12:18:01 +01:00
|
|
|
|
:title="isLazyTrack(track) ? 'Trigger download & play once available' : 'Play'"
|
2025-10-18 14:35:19 +02:00
|
|
|
|
>
|
|
|
|
|
|
▶️
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
@click.stop="handleDeleteTrack(track.pk)"
|
|
|
|
|
|
class="btn-delete"
|
|
|
|
|
|
:disabled="deletingTracks.has(track.pk)"
|
|
|
|
|
|
title="Delete"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ deletingTracks.has(track.pk) ? "..." : "🗑️" }}
|
|
|
|
|
|
</button>
|
2025-12-15 12:18:01 +01:00
|
|
|
|
<button
|
|
|
|
|
|
@click.stop="downloadTrack(track.pk)"
|
|
|
|
|
|
class="btn-secondary"
|
|
|
|
|
|
:disabled="isLazyTrack(track)"
|
|
|
|
|
|
:title="
|
|
|
|
|
|
isLazyTrack(track)
|
|
|
|
|
|
? 'Download available once audio finished downloading'
|
|
|
|
|
|
: 'Download original file'
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
⬇️
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
@click.stop="copyTrackUrl(track.pk)"
|
|
|
|
|
|
class="btn-secondary"
|
|
|
|
|
|
:disabled="isLazyTrack(track)"
|
|
|
|
|
|
:title="
|
|
|
|
|
|
isLazyTrack(track)
|
|
|
|
|
|
? 'URL available after download completes'
|
|
|
|
|
|
: 'Copy stream URL'
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
📋
|
|
|
|
|
|
</button>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Modal de détails -->
|
|
|
|
|
|
<div v-if="selectedTrack" class="modal" @click="selectedTrack = null">
|
|
|
|
|
|
<div class="modal-content" @click.stop>
|
|
|
|
|
|
<button class="modal-close" @click="selectedTrack = null">✕</button>
|
|
|
|
|
|
<div class="modal-header">
|
2025-11-25 08:24:08 +01:00
|
|
|
|
<img
|
|
|
|
|
|
v-if="getTrackCoverUrl(selectedTrack, 200)"
|
|
|
|
|
|
:src="getTrackCoverUrl(selectedTrack, 200)"
|
|
|
|
|
|
:alt="`Cover for ${selectedTrack.metadata?.title || 'Unknown'}`"
|
|
|
|
|
|
class="modal-cover-image"
|
|
|
|
|
|
@error="handleCoverError(selectedTrack.pk)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div v-else class="modal-icon">🎵</div>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
<h3>Track Details</h3>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-info">
|
|
|
|
|
|
<div class="metadata-section" v-if="selectedTrack.metadata">
|
|
|
|
|
|
<h4>Metadata</h4>
|
|
|
|
|
|
<p><strong>Title:</strong> {{ selectedTrack.metadata.title || "Unknown" }}</p>
|
|
|
|
|
|
<p><strong>Artist:</strong> {{ selectedTrack.metadata.artist || "Unknown" }}</p>
|
|
|
|
|
|
<p v-if="selectedTrack.metadata.album"><strong>Album:</strong> {{ selectedTrack.metadata.album }}</p>
|
|
|
|
|
|
<p v-if="selectedTrack.metadata.year"><strong>Year:</strong> {{ selectedTrack.metadata.year }}</p>
|
|
|
|
|
|
<p v-if="selectedTrack.metadata.genre"><strong>Genre:</strong> {{ selectedTrack.metadata.genre }}</p>
|
|
|
|
|
|
<p v-if="selectedTrack.metadata.track_number"><strong>Track:</strong> {{ selectedTrack.metadata.track_number }}</p>
|
2025-10-26 18:30:50 +01:00
|
|
|
|
<p v-if="durationMs(selectedTrack) !== undefined">
|
|
|
|
|
|
<strong>Duration:</strong> {{ formatDuration(durationMs(selectedTrack)!) }}
|
|
|
|
|
|
</p>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
<p v-if="selectedTrack.metadata.sample_rate"><strong>Sample Rate:</strong> {{ formatSampleRate(selectedTrack.metadata.sample_rate) }}</p>
|
|
|
|
|
|
<p v-if="selectedTrack.metadata.bitrate"><strong>Bitrate:</strong> {{ formatBitrate(selectedTrack.metadata.bitrate) }}</p>
|
|
|
|
|
|
<p v-if="selectedTrack.metadata.channels"><strong>Channels:</strong> {{ selectedTrack.metadata.channels }}</p>
|
2025-10-28 00:10:51 +01:00
|
|
|
|
<p v-if="conversionLabel(selectedTrack)"><strong>Conversion:</strong> {{ conversionLabel(selectedTrack) }}</p>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="cache-section">
|
|
|
|
|
|
<h4>Cache Info</h4>
|
|
|
|
|
|
<p><strong>PK:</strong> {{ selectedTrack.pk }}</p>
|
2025-12-15 12:18:01 +01:00
|
|
|
|
<p><strong>Status:</strong> {{ trackStatusLabel(selectedTrack) }}</p>
|
2025-10-26 18:30:50 +01:00
|
|
|
|
<p v-if="resolveTrackOrigin(selectedTrack)">
|
|
|
|
|
|
<strong>Source URL:</strong>
|
|
|
|
|
|
<a :href="resolveTrackOrigin(selectedTrack)" target="_blank">{{ resolveTrackOrigin(selectedTrack) }}</a>
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p v-else><strong>Source URL:</strong> Unknown</p>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
<p><strong>Hits:</strong> {{ selectedTrack.hits }}</p>
|
|
|
|
|
|
<p v-if="selectedTrack.collection"><strong>Collection:</strong> {{ selectedTrack.collection }}</p>
|
|
|
|
|
|
<p v-if="selectedTrack.last_used"><strong>Last Used:</strong> {{ formatDate(selectedTrack.last_used) }}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="modal-actions">
|
|
|
|
|
|
<button @click="playTrack(selectedTrack.pk)" class="btn-play">
|
|
|
|
|
|
▶️ Play
|
|
|
|
|
|
</button>
|
2025-12-15 12:18:01 +01:00
|
|
|
|
<button
|
|
|
|
|
|
@click="downloadTrack(selectedTrack.pk)"
|
|
|
|
|
|
class="btn-secondary"
|
|
|
|
|
|
:disabled="isLazyTrack(selectedTrack)"
|
|
|
|
|
|
>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
⬇️ Download
|
|
|
|
|
|
</button>
|
2025-12-15 12:18:01 +01:00
|
|
|
|
<button
|
|
|
|
|
|
@click="copyTrackUrl(selectedTrack.pk)"
|
|
|
|
|
|
class="btn-secondary"
|
|
|
|
|
|
:disabled="isLazyTrack(selectedTrack)"
|
|
|
|
|
|
>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
📋 Copy URL
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button @click="handleDeleteTrack(selectedTrack.pk); selectedTrack = null" class="btn-danger">
|
|
|
|
|
|
🗑️ Delete
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-19 02:05:02 +02:00
|
|
|
|
<!-- Lecteur audio -->
|
|
|
|
|
|
<div v-if="isPlaying || audioError" class="audio-player-container">
|
|
|
|
|
|
<audio
|
|
|
|
|
|
ref="audioPlayer"
|
|
|
|
|
|
controls
|
|
|
|
|
|
v-if="!audioError"
|
|
|
|
|
|
@ended="handleAudioEnded"
|
|
|
|
|
|
@error="handleAudioError"
|
|
|
|
|
|
></audio>
|
|
|
|
|
|
<p v-if="audioError" class="audio-error">{{ audioError }}</p>
|
|
|
|
|
|
<button @click="stopTrack" class="btn-stop" title="Stop">
|
|
|
|
|
|
{{ audioError ? '✕ Close' : '⏹️ Stop' }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { ref, computed, onMounted } from "vue";
|
|
|
|
|
|
import type { AudioCacheEntry } from "../services/audioCache";
|
|
|
|
|
|
import {
|
|
|
|
|
|
listTracks,
|
|
|
|
|
|
addTrack,
|
|
|
|
|
|
deleteTrack,
|
|
|
|
|
|
purgeCache,
|
|
|
|
|
|
consolidateCache,
|
|
|
|
|
|
getTrackUrl,
|
|
|
|
|
|
getOriginalTrackUrl,
|
2025-10-26 18:30:50 +01:00
|
|
|
|
getOriginUrl,
|
|
|
|
|
|
getDurationMs,
|
2025-10-18 14:35:19 +02:00
|
|
|
|
formatDuration,
|
|
|
|
|
|
formatBitrate,
|
|
|
|
|
|
formatSampleRate,
|
2025-11-25 08:24:08 +01:00
|
|
|
|
getCoverUrl,
|
2025-10-18 14:35:19 +02:00
|
|
|
|
} from "../services/audioCache";
|
|
|
|
|
|
|
2025-12-15 15:05:35 +01:00
|
|
|
|
interface LazyDisplayInfo {
|
|
|
|
|
|
prefix: string;
|
|
|
|
|
|
display: string;
|
|
|
|
|
|
className: string;
|
|
|
|
|
|
isLegacy: boolean;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const LAZY_PROVIDER_LABELS: Record<string, string> = {
|
|
|
|
|
|
QOBUZ: "Qobuz",
|
|
|
|
|
|
};
|
|
|
|
|
|
const LEGACY_LAZY_INFO: LazyDisplayInfo = {
|
|
|
|
|
|
prefix: "legacy",
|
|
|
|
|
|
display: "Legacy",
|
|
|
|
|
|
className: "lazy-provider-legacy",
|
|
|
|
|
|
isLegacy: true,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-18 14:35:19 +02:00
|
|
|
|
// --- États ---
|
|
|
|
|
|
const tracks = ref<AudioCacheEntry[]>([]);
|
|
|
|
|
|
const selectedTrack = ref<AudioCacheEntry | null>(null);
|
|
|
|
|
|
const isLoading = ref(false);
|
|
|
|
|
|
const sortBy = ref<"hits" | "last_used" | "recent">("hits");
|
|
|
|
|
|
const audioPlayer = ref<HTMLAudioElement | null>(null);
|
2025-12-15 15:05:35 +01:00
|
|
|
|
const LEGACY_LAZY_PREFIX = "L:";
|
2025-10-18 14:35:19 +02:00
|
|
|
|
|
|
|
|
|
|
// Formulaire d'ajout
|
|
|
|
|
|
const newTrackUrl = ref("");
|
|
|
|
|
|
const newTrackCollection = ref("");
|
|
|
|
|
|
const isAdding = ref(false);
|
|
|
|
|
|
const addError = ref("");
|
|
|
|
|
|
const addSuccess = ref("");
|
|
|
|
|
|
|
|
|
|
|
|
// Contrôles
|
|
|
|
|
|
const isConsolidating = ref(false);
|
|
|
|
|
|
const isPurging = ref(false);
|
|
|
|
|
|
const deletingTracks = ref(new Set<string>());
|
|
|
|
|
|
|
2025-10-19 02:05:02 +02:00
|
|
|
|
// Lecteur audio
|
|
|
|
|
|
const isPlaying = ref(false);
|
|
|
|
|
|
const audioError = ref("");
|
|
|
|
|
|
|
2025-11-25 08:24:08 +01:00
|
|
|
|
// Gestion des erreurs de chargement des covers
|
|
|
|
|
|
const failedCovers = ref(new Set<string>());
|
|
|
|
|
|
|
2025-10-18 14:35:19 +02:00
|
|
|
|
// --- Computed ---
|
|
|
|
|
|
const totalHits = computed(() => tracks.value.reduce((sum, t) => sum + t.hits, 0));
|
2025-12-15 12:18:01 +01:00
|
|
|
|
const lazyTracksCount = computed(() =>
|
|
|
|
|
|
tracks.value.reduce((acc, track) => (isLazyTrack(track) ? acc + 1 : acc), 0)
|
|
|
|
|
|
);
|
2025-10-18 14:35:19 +02:00
|
|
|
|
|
|
|
|
|
|
const sortedTracks = computed(() => {
|
|
|
|
|
|
const arr = [...tracks.value];
|
|
|
|
|
|
switch (sortBy.value) {
|
|
|
|
|
|
case "hits":
|
|
|
|
|
|
return arr.sort((a, b) => b.hits - a.hits);
|
|
|
|
|
|
case "last_used":
|
|
|
|
|
|
return arr.sort((a, b) => {
|
|
|
|
|
|
if (!a.last_used) return 1;
|
|
|
|
|
|
if (!b.last_used) return -1;
|
|
|
|
|
|
return new Date(b.last_used).getTime() - new Date(a.last_used).getTime();
|
|
|
|
|
|
});
|
|
|
|
|
|
case "recent":
|
|
|
|
|
|
return arr.reverse();
|
|
|
|
|
|
default:
|
|
|
|
|
|
return arr;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// --- Fonctions ---
|
|
|
|
|
|
async function refreshTracks() {
|
|
|
|
|
|
isLoading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
tracks.value = await listTracks();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isLoading.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleAddTrack() {
|
|
|
|
|
|
if (!newTrackUrl.value) return;
|
|
|
|
|
|
isAdding.value = true;
|
|
|
|
|
|
addError.value = "";
|
|
|
|
|
|
addSuccess.value = "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const result = await addTrack(
|
|
|
|
|
|
newTrackUrl.value,
|
|
|
|
|
|
newTrackCollection.value || undefined
|
|
|
|
|
|
);
|
|
|
|
|
|
addSuccess.value = `Track added! PK: ${result.pk}`;
|
|
|
|
|
|
newTrackUrl.value = "";
|
|
|
|
|
|
newTrackCollection.value = "";
|
|
|
|
|
|
await refreshTracks();
|
|
|
|
|
|
} catch (e: any) {
|
|
|
|
|
|
addError.value = e.message ?? "Failed to add track";
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isAdding.value = false;
|
|
|
|
|
|
setTimeout(() => (addSuccess.value = ""), 3000);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDeleteTrack(pk: string) {
|
|
|
|
|
|
if (!confirm(`Delete track ${pk}?`)) return;
|
|
|
|
|
|
deletingTracks.value.add(pk);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deleteTrack(pk);
|
|
|
|
|
|
await refreshTracks();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
deletingTracks.value.delete(pk);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handlePurge() {
|
|
|
|
|
|
if (!confirm("⚠️ Delete ALL tracks?")) return;
|
|
|
|
|
|
isPurging.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await purgeCache();
|
|
|
|
|
|
await refreshTracks();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isPurging.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleConsolidate() {
|
|
|
|
|
|
if (!confirm("Consolidate cache? This will re-download missing tracks.")) return;
|
|
|
|
|
|
isConsolidating.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await consolidateCache();
|
|
|
|
|
|
await refreshTracks();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
isConsolidating.value = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function playTrack(pk: string) {
|
2025-10-19 02:05:02 +02:00
|
|
|
|
audioError.value = "";
|
|
|
|
|
|
isPlaying.value = true;
|
|
|
|
|
|
|
|
|
|
|
|
// Attendre que le DOM soit mis à jour (car le lecteur audio est dans un v-if)
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
if (audioPlayer.value) {
|
|
|
|
|
|
const url = getTrackUrl(pk);
|
|
|
|
|
|
audioPlayer.value.src = url;
|
|
|
|
|
|
audioPlayer.value.play().catch((error) => {
|
|
|
|
|
|
console.error("Failed to play audio:", error);
|
|
|
|
|
|
audioError.value = `Cannot play audio: ${error.message}. Your browser may not support FLAC format.`;
|
|
|
|
|
|
isPlaying.value = false;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function stopTrack() {
|
2025-10-18 14:35:19 +02:00
|
|
|
|
if (audioPlayer.value) {
|
2025-10-19 02:05:02 +02:00
|
|
|
|
audioPlayer.value.pause();
|
|
|
|
|
|
audioPlayer.value.currentTime = 0;
|
|
|
|
|
|
audioPlayer.value.src = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
isPlaying.value = false;
|
|
|
|
|
|
audioError.value = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleAudioEnded() {
|
|
|
|
|
|
isPlaying.value = false;
|
|
|
|
|
|
audioError.value = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleAudioError() {
|
|
|
|
|
|
const audio = audioPlayer.value;
|
|
|
|
|
|
if (audio?.error) {
|
|
|
|
|
|
let message = "Audio playback error: ";
|
|
|
|
|
|
switch (audio.error.code) {
|
|
|
|
|
|
case 1:
|
|
|
|
|
|
message += "Loading aborted";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 2:
|
|
|
|
|
|
message += "Network error";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 3:
|
|
|
|
|
|
message += "Format not supported";
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 4:
|
|
|
|
|
|
message += "Source not found";
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
message += "Unknown error";
|
|
|
|
|
|
}
|
|
|
|
|
|
console.error('Audio player error:', message, 'code:', audio.error.code);
|
|
|
|
|
|
audioError.value = message;
|
|
|
|
|
|
isPlaying.value = false;
|
2025-10-18 14:35:19 +02:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function downloadTrack(pk: string) {
|
2025-12-15 12:18:01 +01:00
|
|
|
|
const track = getTrackByPk(pk);
|
|
|
|
|
|
if (track && isLazyTrack(track)) {
|
|
|
|
|
|
alert("This track is still in lazy cache. Play it once to download the audio before exporting.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-18 14:35:19 +02:00
|
|
|
|
window.open(getOriginalTrackUrl(pk), "_blank");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function copyTrackUrl(pk: string) {
|
2025-12-15 12:18:01 +01:00
|
|
|
|
const track = getTrackByPk(pk);
|
|
|
|
|
|
if (track && isLazyTrack(track)) {
|
|
|
|
|
|
alert("URL available after the lazy audio has been downloaded.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-10-18 14:35:19 +02:00
|
|
|
|
navigator.clipboard.writeText(window.location.origin + getTrackUrl(pk));
|
|
|
|
|
|
alert("✅ URL copied!");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-26 18:30:50 +01:00
|
|
|
|
function resolveTrackOrigin(track: AudioCacheEntry | null): string | undefined {
|
|
|
|
|
|
return track ? getOriginUrl(track) : undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function durationMs(track: AudioCacheEntry | null): number | undefined {
|
|
|
|
|
|
return track ? getDurationMs(track.metadata) : undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 14:35:19 +02:00
|
|
|
|
function formatDate(dateString: string) {
|
|
|
|
|
|
const d = new Date(dateString);
|
|
|
|
|
|
const diff = Date.now() - d.getTime();
|
|
|
|
|
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
|
|
|
|
|
if (days === 0) return "Today";
|
|
|
|
|
|
if (days === 1) return "Yesterday";
|
|
|
|
|
|
if (days < 7) return `${days} days ago`;
|
|
|
|
|
|
return d.toLocaleDateString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-28 00:10:51 +01:00
|
|
|
|
function formatConversion(
|
|
|
|
|
|
conversion?: { mode?: string; input_codec?: string; details?: string } | null
|
|
|
|
|
|
): string | undefined {
|
|
|
|
|
|
if (!conversion || !conversion.mode) return undefined;
|
|
|
|
|
|
const modeLower = conversion.mode.toLowerCase();
|
|
|
|
|
|
const modeLabel =
|
|
|
|
|
|
modeLower === "passthrough"
|
|
|
|
|
|
? "Passthrough"
|
|
|
|
|
|
: modeLower === "transcode"
|
|
|
|
|
|
? "Transcoded"
|
|
|
|
|
|
: conversion.mode.charAt(0).toUpperCase() + conversion.mode.slice(1);
|
|
|
|
|
|
|
|
|
|
|
|
if (conversion.input_codec) {
|
|
|
|
|
|
const codec = conversion.input_codec.toUpperCase();
|
|
|
|
|
|
if (modeLower === "passthrough") {
|
|
|
|
|
|
return `${modeLabel} (${codec})`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return `${modeLabel} (${codec} → FLAC)`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (conversion.details) {
|
|
|
|
|
|
return `${modeLabel} – ${conversion.details}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return modeLabel;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function conversionLabel(track: AudioCacheEntry | null): string | undefined {
|
|
|
|
|
|
return formatConversion(track?.metadata?.conversion ?? undefined);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 08:24:08 +01:00
|
|
|
|
function getTrackCoverUrl(track: AudioCacheEntry | null, size?: number): string | undefined {
|
|
|
|
|
|
if (!track || failedCovers.value.has(track.pk)) return undefined;
|
|
|
|
|
|
return getCoverUrl(track.metadata, size);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function handleCoverError(pk: string) {
|
|
|
|
|
|
failedCovers.value.add(pk);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 15:05:35 +01:00
|
|
|
|
function prettifyLazyProvider(prefix: string): string {
|
|
|
|
|
|
if (LAZY_PROVIDER_LABELS[prefix]) {
|
|
|
|
|
|
return LAZY_PROVIDER_LABELS[prefix];
|
|
|
|
|
|
}
|
|
|
|
|
|
const normalized = prefix.replace(/[^A-Za-z0-9]+/g, " ").trim();
|
|
|
|
|
|
if (!normalized) {
|
|
|
|
|
|
return prefix.trim().toUpperCase() || "Lazy";
|
|
|
|
|
|
}
|
|
|
|
|
|
return normalized
|
|
|
|
|
|
.split(/\s+/)
|
|
|
|
|
|
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1).toLowerCase())
|
|
|
|
|
|
.join(" ");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function buildLazyClass(prefix: string): string {
|
|
|
|
|
|
return `lazy-provider-${prefix.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractLazyInfoFromPk(pk: string | undefined | null): LazyDisplayInfo | undefined {
|
|
|
|
|
|
if (!pk) return undefined;
|
|
|
|
|
|
if (pk.startsWith(LEGACY_LAZY_PREFIX)) {
|
|
|
|
|
|
return LEGACY_LAZY_INFO;
|
|
|
|
|
|
}
|
|
|
|
|
|
const separatorIndex = pk.indexOf(":");
|
|
|
|
|
|
if (separatorIndex <= 0) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
const prefix = pk.slice(0, separatorIndex);
|
|
|
|
|
|
if (!prefix) {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
return {
|
|
|
|
|
|
prefix,
|
|
|
|
|
|
display: prettifyLazyProvider(prefix),
|
|
|
|
|
|
className: buildLazyClass(prefix),
|
|
|
|
|
|
isLegacy: false,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getLazyInfo(track: AudioCacheEntry | null | undefined): LazyDisplayInfo | undefined {
|
|
|
|
|
|
if (!track?.pk) return undefined;
|
|
|
|
|
|
return extractLazyInfoFromPk(track.pk);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 12:18:01 +01:00
|
|
|
|
function isLazyTrack(track: AudioCacheEntry | null | undefined): boolean {
|
2025-12-15 15:05:35 +01:00
|
|
|
|
return !!getLazyInfo(track);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function lazyProviderName(track: AudioCacheEntry | null | undefined): string | undefined {
|
|
|
|
|
|
return getLazyInfo(track)?.display;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function lazyProviderClass(track: AudioCacheEntry | null | undefined): string {
|
|
|
|
|
|
return getLazyInfo(track)?.className ?? "lazy-provider-generic";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function lazyBadgeLabel(track: AudioCacheEntry | null | undefined): string {
|
|
|
|
|
|
const info = getLazyInfo(track);
|
|
|
|
|
|
if (!info) return "";
|
|
|
|
|
|
return info.isLegacy ? "Lazy" : `Lazy - ${info.display}`;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackStatusLabel(track: AudioCacheEntry | null): string {
|
2025-12-15 15:05:35 +01:00
|
|
|
|
const info = getLazyInfo(track);
|
|
|
|
|
|
if (info) {
|
|
|
|
|
|
const provider = info.isLegacy ? "" : ` - ${info.display}`;
|
|
|
|
|
|
return `Lazy${provider} (audio pending download)`;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
return "Cached";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getTrackByPk(pk: string): AudioCacheEntry | undefined {
|
|
|
|
|
|
return tracks.value.find((t) => t.pk === pk);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-19 02:05:02 +02:00
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
refreshTracks();
|
|
|
|
|
|
});
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.audio-cache-manager {
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (min-width: 1400px) {
|
|
|
|
|
|
.audio-cache-manager {
|
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
|
max-width: 1400px;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.audio-cache-manager {
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-bottom: 1.5rem;
|
|
|
|
|
|
padding-bottom: 1rem;
|
|
|
|
|
|
border-bottom: 2px solid #444;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header h2 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: #61dafb;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Formulaire d'ajout */
|
|
|
|
|
|
.add-form {
|
|
|
|
|
|
background: #2a2a2a;
|
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
margin-bottom: 1.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.add-form h3 {
|
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
|
color: #61dafb;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-group {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-group input {
|
|
|
|
|
|
padding: 0.75rem;
|
|
|
|
|
|
border: 1px solid #444;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: #1a1a1a;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-group input[type="url"] {
|
|
|
|
|
|
flex: 2;
|
|
|
|
|
|
min-width: 250px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.collection-input {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 150px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-group button {
|
|
|
|
|
|
padding: 0.75rem 1.5rem;
|
|
|
|
|
|
background: #61dafb;
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-group button:hover:not(:disabled) {
|
|
|
|
|
|
background: #4fa8c5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-group button:disabled {
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error {
|
|
|
|
|
|
color: #ff6b6b;
|
|
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.success {
|
|
|
|
|
|
color: #51cf66;
|
|
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Contrôles */
|
|
|
|
|
|
.controls {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
margin-bottom: 1.5rem;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
background: #2a2a2a;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sort-controls {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sort-controls label {
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sort-controls select {
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
border: 1px solid #444;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
background: #1a1a1a;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button {
|
|
|
|
|
|
padding: 0.5rem 1rem;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button:not(.btn-danger):not(.btn-secondary):not(.btn-play):not(.btn-delete) {
|
|
|
|
|
|
background: #61dafb;
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button:not(.btn-danger):not(.btn-secondary):not(.btn-play):not(.btn-delete):hover:not(:disabled) {
|
|
|
|
|
|
background: #4fa8c5;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-secondary {
|
|
|
|
|
|
background: #555;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-secondary:hover:not(:disabled) {
|
|
|
|
|
|
background: #666;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-danger {
|
|
|
|
|
|
background: #ff6b6b;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-danger:hover:not(:disabled) {
|
|
|
|
|
|
background: #ee5a52;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-play {
|
|
|
|
|
|
background: #51cf66;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-play:hover:not(:disabled) {
|
|
|
|
|
|
background: #40c057;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button:disabled {
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* États */
|
|
|
|
|
|
.loading-state,
|
|
|
|
|
|
.empty-state {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
padding: 3rem;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
font-size: 1.2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Grille de pistes */
|
|
|
|
|
|
.track-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
|
|
|
|
gap: 1.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-card {
|
|
|
|
|
|
background: #2a2a2a;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-card:hover {
|
|
|
|
|
|
transform: translateY(-4px);
|
|
|
|
|
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-icon {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
padding-top: 56.25%; /* Ratio 16:9 */
|
|
|
|
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 08:24:08 +01:00
|
|
|
|
.cover-image {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 14:35:19 +02:00
|
|
|
|
.music-icon {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 50%;
|
|
|
|
|
|
left: 50%;
|
|
|
|
|
|
transform: translate(-50%, -50%);
|
|
|
|
|
|
font-size: 4rem;
|
|
|
|
|
|
opacity: 0.3;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-overlay {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.hits {
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 12:18:01 +01:00
|
|
|
|
.track-overlay .hits.lazy {
|
2025-12-15 15:05:35 +01:00
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
background: rgba(156, 39, 176, 0.85);
|
|
|
|
|
|
color: #fff;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
font-weight: bold;
|
2025-12-15 15:05:35 +01:00
|
|
|
|
padding: 0.2rem 0.6rem;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
border-radius: 999px;
|
2025-12-15 15:05:35 +01:00
|
|
|
|
font-size: 0.8rem;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 14:35:19 +02:00
|
|
|
|
.track-info {
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-title {
|
|
|
|
|
|
font-size: 1.1rem;
|
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
|
color: #61dafb;
|
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-artist {
|
|
|
|
|
|
color: #ccc;
|
|
|
|
|
|
margin-bottom: 0.25rem;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-album {
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pk {
|
|
|
|
|
|
font-family: monospace;
|
|
|
|
|
|
color: #777;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 12:18:01 +01:00
|
|
|
|
.lazy-tag {
|
2025-12-15 15:05:35 +01:00
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 0.35rem;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
margin-left: 0.5rem;
|
2025-12-15 15:05:35 +01:00
|
|
|
|
padding: 0.15rem 0.6rem;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
border-radius: 999px;
|
2025-12-15 15:05:35 +01:00
|
|
|
|
background: rgba(156, 39, 176, 0.25);
|
|
|
|
|
|
color: #f5f5f5;
|
|
|
|
|
|
font-size: 0.7rem;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
text-transform: uppercase;
|
2025-12-15 15:05:35 +01:00
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
border: 1px solid rgba(156, 39, 176, 0.4);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lazy-tag .lazy-label {
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
letter-spacing: 0.5px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.lazy-tag .lazy-provider-name {
|
|
|
|
|
|
font-size: 0.6rem;
|
|
|
|
|
|
text-transform: none;
|
|
|
|
|
|
letter-spacing: 0.4px;
|
|
|
|
|
|
opacity: 0.9;
|
2025-12-15 12:18:01 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 14:35:19 +02:00
|
|
|
|
.meta {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.75rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: #999;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.collection {
|
|
|
|
|
|
color: #888;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
|
font-style: italic;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.last-used {
|
|
|
|
|
|
color: #777;
|
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
|
margin-top: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 12:18:01 +01:00
|
|
|
|
.lazy-warning {
|
|
|
|
|
|
margin-top: 0.75rem;
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: #ffb347;
|
|
|
|
|
|
background: rgba(255, 152, 0, 0.15);
|
|
|
|
|
|
border: 1px solid rgba(255, 152, 0, 0.3);
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-15 15:05:35 +01:00
|
|
|
|
.track-overlay .hits.lazy.lazy-provider-legacy,
|
|
|
|
|
|
.lazy-tag.lazy-provider-legacy {
|
|
|
|
|
|
background: rgba(255, 152, 0, 0.85);
|
|
|
|
|
|
color: #000;
|
|
|
|
|
|
border-color: rgba(255, 193, 7, 0.8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-overlay .hits.lazy.lazy-provider-qobuz,
|
|
|
|
|
|
.lazy-tag.lazy-provider-qobuz {
|
|
|
|
|
|
background: rgba(76, 175, 80, 0.9);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-color: rgba(165, 214, 167, 0.9);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-overlay .hits.lazy.lazy-provider-generic,
|
|
|
|
|
|
.lazy-tag.lazy-provider-generic {
|
|
|
|
|
|
background: rgba(103, 58, 183, 0.85);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border-color: rgba(179, 157, 219, 0.8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 14:35:19 +02:00
|
|
|
|
.track-actions {
|
|
|
|
|
|
padding: 0 1rem 1rem;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-actions button {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* Modal */
|
|
|
|
|
|
.modal {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
top: 0;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
bottom: 0;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.9);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
z-index: 1000;
|
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-content {
|
|
|
|
|
|
background: #2a2a2a;
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
max-width: 700px;
|
|
|
|
|
|
max-height: 90vh;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-close {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 1rem;
|
|
|
|
|
|
right: 1rem;
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.5);
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
width: 32px;
|
|
|
|
|
|
height: 32px;
|
|
|
|
|
|
border-radius: 50%;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-size: 1.2rem;
|
|
|
|
|
|
z-index: 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-close:hover {
|
|
|
|
|
|
background: rgba(0, 0, 0, 0.8);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-header {
|
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
|
border-bottom: 1px solid #444;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-25 08:24:08 +01:00
|
|
|
|
.modal-cover-image {
|
|
|
|
|
|
width: 80px;
|
|
|
|
|
|
height: 80px;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-18 14:35:19 +02:00
|
|
|
|
.modal-icon {
|
|
|
|
|
|
font-size: 3rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-header h3 {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: #61dafb;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-info {
|
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.metadata-section,
|
|
|
|
|
|
.cache-section {
|
|
|
|
|
|
margin-bottom: 1.5rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.metadata-section h4,
|
|
|
|
|
|
.cache-section h4 {
|
|
|
|
|
|
color: #61dafb;
|
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
|
margin-bottom: 1rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-info p {
|
|
|
|
|
|
margin: 0.5rem 0;
|
|
|
|
|
|
color: #ccc;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-info a {
|
|
|
|
|
|
color: #61dafb;
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-info a:hover {
|
|
|
|
|
|
text-decoration: underline;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-actions {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
margin-top: 1.5rem;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.modal-actions button {
|
|
|
|
|
|
flex: 1;
|
|
|
|
|
|
min-width: 120px;
|
|
|
|
|
|
}
|
2025-10-19 02:05:02 +02:00
|
|
|
|
|
|
|
|
|
|
/* Lecteur audio */
|
|
|
|
|
|
.audio-player-container {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
bottom: 2rem;
|
|
|
|
|
|
right: 2rem;
|
|
|
|
|
|
background: #2a2a2a;
|
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 1rem;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
z-index: 1001;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.audio-player-container audio {
|
|
|
|
|
|
max-width: 400px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.audio-error {
|
|
|
|
|
|
color: #ff6b6b;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 0.5rem;
|
|
|
|
|
|
background: rgba(255, 107, 107, 0.1);
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
max-width: 400px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-stop {
|
|
|
|
|
|
background: #ff6b6b;
|
|
|
|
|
|
color: #fff;
|
|
|
|
|
|
padding: 0.75rem 1.5rem;
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-stop:hover:not(:disabled) {
|
|
|
|
|
|
background: #ee5a52;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
|
|
|
|
|
.audio-player-container {
|
|
|
|
|
|
bottom: 1rem;
|
|
|
|
|
|
right: 1rem;
|
|
|
|
|
|
left: 1rem;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.audio-player-container audio {
|
|
|
|
|
|
max-width: 100%;
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-10-18 14:35:19 +02:00
|
|
|
|
</style>
|