Refactoring profond de pmoparadise

This commit is contained in:
2025-10-21 14:21:17 +02:00
parent b95bebdb6a
commit 2bb2e7b7db
19 changed files with 3960 additions and 1165 deletions

View File

@@ -166,73 +166,221 @@
</div>
</div>
<div class="channel-tracks-section">
<div class="channel-status-section">
<div class="section-header">
<h3>🎧 Channel Tracks (Source API)</h3>
<button class="btn-secondary" @click="fetchChannelTracks" :disabled="channelBrowseLoading">
<span v-if="channelBrowseLoading"> Refreshing</span>
<span v-else>Refresh Tracks</span>
<h3>📡 Channel Status</h3>
<button class="btn-secondary" @click="fetchChannelStatus" :disabled="channelStatusLoading">
<span v-if="channelStatusLoading"> Refreshing</span>
<span v-else>Refresh Status</span>
</button>
</div>
<div v-if="channelStatusError" class="error-message">
{{ channelStatusError }}
</div>
<div v-else-if="channelStatus">
<div class="status-grid">
<div class="status-card">
<div class="status-label">Channel</div>
<div class="status-value">{{ channelStatus.slug }}</div>
</div>
<div class="status-card">
<div class="status-label">Active Clients</div>
<div class="status-value">{{ channelStatus.active_clients }}</div>
</div>
<div class="status-card">
<div class="status-label">Queue Length</div>
<div class="status-value">{{ channelStatus.queue_length }}</div>
</div>
<div class="status-card">
<div class="status-label">Update ID</div>
<div class="status-value">{{ channelStatus.update_id }}</div>
</div>
<div class="status-card">
<div class="status-label">Last Change</div>
<div class="status-value">
{{ channelStatus.last_change ? formatTimestamp(new Date(channelStatus.last_change)) : '—' }}
</div>
</div>
<div class="status-card">
<div class="status-label">History Entries</div>
<div class="status-value">
{{ channelStatus.history_entries }} / {{ channelStatus.history_max_tracks }}
</div>
</div>
<div class="status-card">
<div class="status-label">Cache Collection</div>
<div class="status-value">{{ channelStatus.cache_collection_id }}</div>
</div>
<div class="status-card">
<div class="status-label">Cache Tracks</div>
<div class="status-value">
{{ channelStatus.cache_cached_tracks }} / {{ channelStatus.cache_total_tracks }}
</div>
</div>
</div>
<div class="section-meta" v-if="channelStatusLastUpdated">
Last refresh: {{ formatTimestamp(channelStatusLastUpdated) }}
</div>
</div>
</div>
<div class="channel-tracks-section">
<div class="section-header">
<h3>🎧 Live Playlist</h3>
<div class="section-actions">
<button class="btn-tertiary" @click="refreshChannelData" :disabled="channelPlaylistLoading || channelStatusLoading">
<span v-if="channelPlaylistLoading || channelStatusLoading"> Refreshing</span>
<span v-else>Refresh All</span>
</button>
<button class="btn-secondary" @click="fetchChannelPlaylist" :disabled="channelPlaylistLoading">
<span v-if="channelPlaylistLoading"> Loading</span>
<span v-else>Refresh Playlist</span>
</button>
</div>
</div>
<div class="section-meta">
<span>Object:</span>
<code>{{ channelBrowse.object_id || channelObjectId(selectedChannel) }}</code>
<span>Items:</span>
<strong>{{ channelBrowse.returned_items }}</strong>
<span v-if="channelBrowse.total">/ {{ channelBrowse.total }}</span>
<span v-if="channelBrowse.update_id">Update ID: {{ channelBrowse.update_id }}</span>
<span v-if="channelBrowseLastUpdated">
Last refresh: {{ formatTimestamp(channelBrowseLastUpdated) }}
<span>Queue length:</span>
<strong>{{ channelPlaylist.queue_length }}</strong>
<span v-if="channelPlaylist.update_id">Update ID: {{ channelPlaylist.update_id }}</span>
<span v-if="channelPlaylistLastUpdated">
Last refresh: {{ formatTimestamp(channelPlaylistLastUpdated) }}
</span>
</div>
<div v-if="channelBrowseError" class="error-message">
{{ channelBrowseError }}
<div v-if="channelPlaylistError" class="error-message">
{{ channelPlaylistError }}
</div>
<div v-else-if="channelBrowseLoading" class="loading-message">
Loading channel tracks
<div v-else-if="channelPlaylistLoading" class="loading-message">
Loading playlist
</div>
<div v-else>
<div v-if="channelBrowse.containers.length" class="sub-container-notice">
{{ channelBrowse.containers.length }} sub container(s) available.
</div>
<div v-if="channelBrowse.items.length" class="track-grid">
<div v-if="channelPlaylist.items.length" class="track-grid">
<div
v-for="item in channelBrowse.items"
:key="item.id"
:class="['track-card', { active: activeTrackId === item.id }]"
v-for="item in channelPlaylist.items"
:key="trackObjectId(item)"
:class="['track-card', { active: activeTrackId === trackObjectId(item) }]"
>
<div class="track-card-header">
<span :class="trackStatusClass(item.__status)">
{{ trackStatusLabel(item.__status) }}
<span :class="cacheStatusClass(item.cache_status)">
{{ cacheStatusLabel(item.cache_status) }}
</span>
<span v-if="item.update_id" class="track-metadata">update {{ item.update_id }}</span>
<span class="track-metadata">Pending listeners: {{ item.pending_clients }}</span>
</div>
<div class="track-headline">
<div class="track-title">{{ item.title }}</div>
<div class="track-artist">{{ item.artist || item.creator || 'Unknown artist' }}</div>
<div class="track-artist">{{ item.artist || 'Unknown artist' }}</div>
</div>
<div class="track-meta">
<span v-if="item.album">{{ item.album }}</span>
<span v-if="item.resources && item.resources.length && item.resources[0].duration">
{{ item.resources[0].duration }}
</span>
<span v-if="item.duration_ms"> {{ formatDuration(item.duration_ms) }}</span>
<span v-if="item.elapsed_ms"> @{{ formatDuration(item.elapsed_ms) }}</span>
<span v-if="item.started_at">🕒 {{ formatTimestamp(new Date(item.started_at)) }}</span>
<span v-if="item.cache_status?.size_bytes">💾 {{ formatBytes(item.cache_status.size_bytes) }}</span>
</div>
<div class="track-actions">
<button class="btn-secondary" @click="playTrackItem(item)"> Play Track</button>
<button
class="btn-secondary"
@click="requestCacheForTrack(item)"
:disabled="trackExtrasFor(item).cacheRequestLoading"
>
<span v-if="trackExtrasFor(item).cacheRequestLoading"> Caching</span>
<span v-else>💾 Request Cache</span>
</button>
<button
class="btn-tertiary"
@click="refreshTrackCacheStatus(item)"
:disabled="trackExtrasFor(item).cacheStatusLoading"
>
<span v-if="trackExtrasFor(item).cacheStatusLoading"> Updating</span>
<span v-else>🔄 Cache Status</span>
</button>
<button
class="btn-tertiary"
@click="fetchTrackFormats(item)"
:disabled="trackExtrasFor(item).formatsLoading"
>
<span v-if="trackExtrasFor(item).formatsLoading"> Formats</span>
<span v-else>🎚 Formats</span>
</button>
<a
v-for="resource in item.resources"
:key="resource.url"
:href="resource.url"
v-if="trackExtrasFor(item).uri"
:href="trackExtrasFor(item).uri"
target="_blank"
class="stream-link"
>
Open resource
Open resolved URI
</a>
</div>
<div v-if="trackExtrasFor(item).cacheRequestMessage" class="inline-success">
{{ trackExtrasFor(item).cacheRequestMessage }}
</div>
<div v-if="trackExtrasFor(item).cacheError" class="inline-error">
{{ trackExtrasFor(item).cacheError }}
</div>
<div v-if="trackExtrasFor(item).resolveError" class="inline-error">
Resolve error: {{ trackExtrasFor(item).resolveError }}
</div>
<div v-if="trackExtrasFor(item).formatsError" class="inline-error">
Formats error: {{ trackExtrasFor(item).formatsError }}
</div>
<div
v-if="trackExtrasFor(item).formats && trackExtrasFor(item).formats.length"
class="formats-list"
>
<div
v-for="format in trackExtrasFor(item).formats"
:key="format.format_id"
class="format-row"
>
<strong>{{ format.format_id }}</strong>
<span>{{ format.mime_type }}</span>
<span v-if="format.sample_rate">{{ format.sample_rate }} Hz</span>
<span v-if="format.bit_depth">{{ format.bit_depth }} bit</span>
<span v-if="format.bitrate">{{ format.bitrate }} kbps</span>
<span v-if="format.channels">{{ format.channels }} ch</span>
</div>
</div>
</div>
</div>
<div v-else class="empty-placeholder">
No cached tracks yet for this channel. Refresh after playback starts.
No tracks currently queued. Try refreshing after playback starts.
</div>
</div>
</div>
<div class="channel-history-section">
<div class="section-header">
<h3>🕰 Recent History</h3>
<button class="btn-secondary" @click="fetchChannelHistory" :disabled="channelHistoryLoading">
<span v-if="channelHistoryLoading"> Loading</span>
<span v-else>Refresh History</span>
</button>
</div>
<div class="section-meta" v-if="channelHistoryLastUpdated">
Last refresh: {{ formatTimestamp(channelHistoryLastUpdated) }}
</div>
<div v-if="channelHistoryError" class="error-message">
{{ channelHistoryError }}
</div>
<div v-else-if="channelHistoryLoading" class="loading-message">
Loading history
</div>
<div v-else class="history-list">
<div v-if="channelHistory.length === 0" class="empty-placeholder">
No history entries yet.
</div>
<div
v-for="entry in channelHistory"
:key="`${entry.track_id}-${entry.started_at}`"
class="history-item"
>
<div class="history-title">{{ entry.title }}</div>
<div class="history-meta">
<span>{{ entry.artist }}</span>
<span v-if="entry.album"> {{ entry.album }}</span>
<span> {{ formatDuration(entry.duration_ms) }}</span>
<span> {{ formatTimestamp(new Date(entry.started_at)) }}</span>
</div>
</div>
</div>
</div>
@@ -379,22 +527,31 @@ const blockSearchLoading = ref(false)
const blockSearchError = ref('')
const channelsError = ref('')
const bitratesError = ref('')
const channelBrowse = ref({
object_id: '',
containers: [],
const channelStatus = ref(null)
const channelStatusLoading = ref(false)
const channelStatusError = ref('')
const channelStatusLastUpdated = ref(null)
const channelPlaylist = ref({
items: [],
returned_containers: 0,
returned_items: 0,
total: 0,
update_id: 0
queue_length: 0,
update_id: 0,
slug: '',
channel_id: selectedChannel.value
})
const channelBrowseLoading = ref(false)
const channelBrowseError = ref('')
const channelBrowseLastUpdated = ref(null)
const channelPlaylistLoading = ref(false)
const channelPlaylistError = ref('')
const channelPlaylistLastUpdated = ref(null)
const channelHistory = ref([])
const channelHistoryLoading = ref(false)
const channelHistoryError = ref('')
const channelHistoryLastUpdated = ref(null)
const trackExtras = ref({})
let refreshTimerId = null
let channelRefreshTimerId = null
const CHANNEL_TRACKS_REFRESH_INTERVAL = 5000
let channelTracksInFlight = false
const CHANNEL_REFRESH_INTERVAL = 7000
// Format duration from milliseconds to MM:SS
function formatDuration(ms) {
@@ -444,6 +601,10 @@ function channelObjectId(channelId) {
return `${SOURCE_ID}:channel:${channelId}`
}
function trackObjectId(item) {
return item?.track_id || item?.id || item?.object_id || ''
}
function playAudio(url) {
if (!url) {
audioError.value = 'No audio URL available'
@@ -496,91 +657,311 @@ async function refreshNowPlaying() {
}
}
function deriveTrackStatus(item) {
const url = item?.resources?.[0]?.url || ''
if (!url) {
return 'pending'
}
if (url.includes('/audio/flac/') && !url.includes('#')) {
return 'cached'
}
if (url.includes('#')) {
return 'downloading'
}
return 'external'
}
function trackStatusLabel(status) {
function cacheStatusLabel(cacheInfo) {
const status = cacheInfo?.status || 'not_cached'
switch (status) {
case 'cached':
return 'Cached'
case 'downloading':
return 'Downloading'
case 'external':
return 'External'
case 'caching':
return cacheInfo?.progress != null
? `Caching ${(cacheInfo.progress * 100).toFixed(0)}%`
: 'Caching'
case 'failed':
return 'Failed'
case 'not_cached':
default:
return 'Pending'
return 'Not cached'
}
}
function trackStatusClass(status) {
function cacheStatusClass(cacheInfo) {
const status = cacheInfo?.status || 'not_cached'
return {
'status-badge': true,
cached: status === 'cached',
downloading: status === 'downloading',
external: status === 'external',
pending: status === 'pending'
caching: status === 'caching',
failed: status === 'failed',
pending: status === 'not_cached'
}
}
async function fetchChannelTracks(options = {}) {
const { silent = false } = options
if (channelTracksInFlight) {
return
function formatBytes(bytes) {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) {
return '0 B'
}
channelTracksInFlight = true
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unitIndex = 0
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024
unitIndex += 1
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`
}
async function fetchChannelStatus({ silent = false } = {}) {
if (!silent) {
channelBrowseLoading.value = true
channelStatusLoading.value = true
}
channelBrowseError.value = ''
channelStatusError.value = ''
try {
const params = new URLSearchParams()
params.set('object_id', channelObjectId(selectedChannel.value))
params.set('requested_count', '0')
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/browse?${params.toString()}`)
const response = await fetch(`${API_BASE}/channels/${selectedChannel.value}/status`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
channelBrowse.value = {
object_id: data.object_id || channelObjectId(selectedChannel.value),
containers: data.containers ?? [],
items: data.items ?? [],
returned_containers: data.returned_containers ?? (data.containers?.length ?? 0),
returned_items: data.returned_items ?? (data.items?.length ?? 0),
total: data.total ?? ((data.containers?.length ?? 0) + (data.items?.length ?? 0)),
update_id: data.update_id ?? 0
}
// annotate each item with status for UI
channelBrowse.value.items = channelBrowse.value.items.map((item) => ({
...item,
__status: deriveTrackStatus(item)
}))
channelBrowseLastUpdated.value = new Date()
channelStatus.value = await response.json()
channelStatusLastUpdated.value = new Date()
} catch (e) {
channelBrowseError.value = `Failed to load channel tracks: ${e.message}`
console.error('Error fetching channel tracks:', e)
channelStatusError.value = `Failed to load channel status: ${e.message}`
console.error('Error fetching channel status:', e)
} finally {
channelTracksInFlight = false
if (!silent) {
channelBrowseLoading.value = false
channelStatusLoading.value = false
}
}
}
async function fetchChannelPlaylist({ silent = false, limit = 24 } = {}) {
if (!silent) {
channelPlaylistLoading.value = true
}
channelPlaylistError.value = ''
try {
const params = new URLSearchParams()
if (limit != null) {
params.set('limit', String(limit))
}
const response = await fetch(
`${API_BASE}/channels/${selectedChannel.value}/playlist?${params.toString()}`
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
channelPlaylist.value = {
...data,
items: (data.items || []).map((item) => ({
...item,
cache_status: item.cache_status || { status: 'not_cached', progress: 0 }
}))
}
channelPlaylistLastUpdated.value = new Date()
const validIds = new Set(channelPlaylist.value.items.map((item) => trackObjectId(item)).filter(Boolean))
trackExtras.value = Object.fromEntries(
Object.entries(trackExtras.value).filter(([id]) => validIds.has(id))
)
} catch (e) {
channelPlaylistError.value = `Failed to load playlist: ${e.message}`
console.error('Error fetching channel playlist:', e)
} finally {
if (!silent) {
channelPlaylistLoading.value = false
}
}
}
async function fetchChannelHistory({ silent = false, limit = 25 } = {}) {
if (!silent) {
channelHistoryLoading.value = true
}
channelHistoryError.value = ''
try {
const params = new URLSearchParams()
if (limit != null) {
params.set('limit', String(limit))
}
const response = await fetch(
`${API_BASE}/channels/${selectedChannel.value}/history?${params.toString()}`
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
channelHistory.value = data.entries || []
channelHistoryLastUpdated.value = new Date()
} catch (e) {
channelHistoryError.value = `Failed to load history: ${e.message}`
console.error('Error fetching channel history:', e)
} finally {
if (!silent) {
channelHistoryLoading.value = false
}
}
}
async function refreshChannelData({ silent = false } = {}) {
await Promise.all([
fetchChannelStatus({ silent }),
fetchChannelPlaylist({ silent }),
fetchChannelHistory({ silent })
])
}
function updateTrackExtras(trackId, patch) {
if (!trackId) {
return
}
const current = trackExtras.value[trackId] || {
uri: '',
lastResolvedAt: null,
resolving: false,
resolveError: '',
formats: [],
formatsLoading: false,
formatsError: '',
cacheRequestLoading: false,
cacheRequestMessage: '',
cacheError: '',
cacheStatusLoading: false
}
trackExtras.value = {
...trackExtras.value,
[trackId]: {
...current,
...patch
}
}
}
function trackExtrasFor(item) {
const trackId = trackObjectId(item)
return trackExtras.value[trackId] || {}
}
async function resolveTrackUri(trackId) {
if (!trackId) {
throw new Error('Missing track identifier')
}
updateTrackExtras(trackId, { resolving: true, resolveError: '' })
try {
const params = new URLSearchParams()
params.set('object_id', trackId)
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/resolve?${params.toString()}`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
updateTrackExtras(trackId, {
resolving: false,
uri: data.uri,
lastResolvedAt: new Date()
})
return data.uri
} catch (e) {
updateTrackExtras(trackId, { resolving: false, resolveError: e.message })
throw e
}
}
async function refreshTrackCacheStatus(item) {
const trackId = trackObjectId(item)
if (!trackId) {
return
}
updateTrackExtras(trackId, { cacheStatusLoading: true, cacheError: '' })
try {
const params = new URLSearchParams()
params.set('object_id', trackId)
const response = await fetch(
`${SOURCE_API_BASE}/${SOURCE_ID}/cache/status?${params.toString()}`
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
const status = data.status || { status: 'not_cached' }
channelPlaylist.value = {
...channelPlaylist.value,
items: channelPlaylist.value.items.map((entry) =>
trackObjectId(entry) === trackId ? { ...entry, cache_status: status } : entry
)
}
updateTrackExtras(trackId, { cacheStatusLoading: false, cacheError: '', cacheStatus: status })
} catch (e) {
updateTrackExtras(trackId, { cacheStatusLoading: false, cacheError: e.message })
console.error('Error refreshing cache status:', e)
}
}
async function requestCacheForTrack(item) {
const trackId = trackObjectId(item)
if (!trackId) {
return
}
updateTrackExtras(trackId, {
cacheRequestLoading: true,
cacheRequestMessage: '',
cacheError: ''
})
try {
const response = await fetch(`${SOURCE_API_BASE}/${SOURCE_ID}/cache`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ object_id: trackId })
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
const message = body?.error || response.statusText
throw new Error(message)
}
const data = await response.json()
const status = data.status || data?.cache_status
if (status) {
channelPlaylist.value = {
...channelPlaylist.value,
items: channelPlaylist.value.items.map((entry) =>
trackObjectId(entry) === trackId ? { ...entry, cache_status: status } : entry
)
}
}
updateTrackExtras(trackId, {
cacheRequestLoading: false,
cacheRequestMessage: 'Cache request accepted'
})
} catch (e) {
updateTrackExtras(trackId, {
cacheRequestLoading: false,
cacheRequestMessage: '',
cacheError: e.message
})
console.error('Error requesting cache:', e)
} finally {
await refreshTrackCacheStatus(item)
}
}
async function fetchTrackFormats(item) {
const trackId = trackObjectId(item)
if (!trackId) {
return
}
const extras = trackExtrasFor(item)
if (extras.formats?.length && !extras.formatsError) {
return
}
updateTrackExtras(trackId, { formatsLoading: true, formatsError: '' })
try {
const params = new URLSearchParams()
params.set('object_id', trackId)
const response = await fetch(
`${SOURCE_API_BASE}/${SOURCE_ID}/formats?${params.toString()}`
)
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
}
const data = await response.json()
updateTrackExtras(trackId, {
formatsLoading: false,
formats: data.formats || []
})
} catch (e) {
updateTrackExtras(trackId, { formatsLoading: false, formatsError: e.message })
console.error('Error fetching track formats:', e)
}
}
// Fetch available channels
async function fetchChannels() {
try {
@@ -635,15 +1016,17 @@ function selectChannel(channelId) {
}
async function changeChannel() {
trackExtras.value = {}
await refreshNowPlaying()
await fetchChannelTracks()
await refreshChannelData()
blockSearchResult.value = null
blockSearchError.value = ''
}
async function changeBitrate() {
trackExtras.value = {}
await refreshNowPlaying()
await fetchChannelTracks()
await refreshChannelData()
blockSearchResult.value = null
blockSearchError.value = ''
}
@@ -744,16 +1127,38 @@ function clearBlockSearch() {
blockSearchError.value = ''
}
function playTrackItem(item) {
const resource = item?.resources?.find(res => res.url)
if (!resource) {
audioError.value = 'No audio resource available for this track'
async function playTrackItem(item) {
const trackId = trackObjectId(item)
if (!trackId) {
audioError.value = 'Unable to determine track identifier'
isPlaying.value = false
return
}
activeTrackId.value = item.id
playAudio(resource.url)
try {
let uri = null
try {
uri = await resolveTrackUri(trackId)
} catch (resolveError) {
console.warn('Falling back to direct resource due to resolve error:', resolveError)
}
if (!uri) {
const fallback = item?.resources?.find((res) => res.url)?.url
uri = fallback
}
if (!uri) {
throw new Error('No audio resource available for this track')
}
activeTrackId.value = trackId
playAudio(uri)
} catch (e) {
audioError.value = e.message
isPlaying.value = false
activeTrackId.value = null
}
}
// Initialize on mount
@@ -761,7 +1166,7 @@ onMounted(async () => {
await fetchChannels()
await fetchBitrates()
await refreshNowPlaying()
await fetchChannelTracks()
await refreshChannelData()
// Auto-refresh every 30 seconds
refreshTimerId = window.setInterval(() => {
@@ -772,10 +1177,10 @@ onMounted(async () => {
// Auto-refresh channel tracks every few seconds
channelRefreshTimerId = window.setInterval(() => {
if (!channelBrowseLoading.value) {
fetchChannelTracks({ silent: true })
if (!channelPlaylistLoading.value && !channelStatusLoading.value) {
refreshChannelData({ silent: true })
}
}, CHANNEL_TRACKS_REFRESH_INTERVAL)
}, CHANNEL_REFRESH_INTERVAL)
})
onUnmounted(() => {
@@ -1326,6 +1731,13 @@ onUnmounted(() => {
color: #9aa0a6;
}
.section-actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.loading-message {
margin-top: 16px;
color: #9aa0a6;
@@ -1364,6 +1776,36 @@ onUnmounted(() => {
transition: border-color 0.2s, box-shadow 0.2s;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-top: 12px;
}
.status-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.04);
border-radius: 8px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.status-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #9aa0a6;
}
.status-value {
font-size: 1.05rem;
font-weight: 600;
color: #f5f5f5;
}
.track-card-header {
display: flex;
justify-content: space-between;
@@ -1393,22 +1835,22 @@ onUnmounted(() => {
color: #2ecc71;
}
.status-badge.downloading {
.status-badge.caching {
background: rgba(255, 193, 7, 0.15);
border-color: rgba(255, 193, 7, 0.4);
color: #ffc107;
}
.status-badge.external {
background: rgba(0, 212, 255, 0.12);
border-color: rgba(0, 212, 255, 0.4);
color: #00d4ff;
.status-badge.failed {
background: rgba(255, 87, 34, 0.18);
border-color: rgba(255, 87, 34, 0.5);
color: #ff7043;
}
.status-badge.pending {
background: rgba(255, 87, 34, 0.15);
border-color: rgba(255, 87, 34, 0.4);
color: #ff6d3a;
background: rgba(0, 212, 255, 0.12);
border-color: rgba(0, 212, 255, 0.4);
color: #00d4ff;
}
.track-metadata {
@@ -1452,6 +1894,63 @@ onUnmounted(() => {
align-items: center;
}
.inline-error {
margin-top: 6px;
font-size: 0.8rem;
color: #ff6b6b;
}
.inline-success {
margin-top: 6px;
font-size: 0.8rem;
color: #2ecc71;
}
.formats-list {
margin-top: 10px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
padding-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
color: #9aa0a6;
}
.format-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.history-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
}
.history-item {
padding: 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.25);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.history-title {
font-weight: 600;
color: #f5f5f5;
}
.history-meta {
margin-top: 4px;
font-size: 0.8rem;
color: #9aa0a6;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.audio-player-container {
margin: 12px 0 24px;
padding: 16px;