2025-12-17 14:26:07 +01:00
|
|
|
|
<template>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
<div class="playlist-manager">
|
|
|
|
|
|
<div class="header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<h2>🎚️ Playlist Manager</h2>
|
|
|
|
|
|
<p class="subtitle">
|
|
|
|
|
|
Inspect pmoplaylist state, debug lazy PKs and tune
|
|
|
|
|
|
capacities.
|
2025-12-17 15:25:02 +01:00
|
|
|
|
</p>
|
2025-12-17 14:26:07 +01:00
|
|
|
|
</div>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
<div class="header-stats">
|
|
|
|
|
|
<span>{{ playlists.length }} playlists</span>
|
|
|
|
|
|
<span>{{ totalTrackCount }} tracks</span>
|
|
|
|
|
|
<span v-if="persistentCount > 0"
|
|
|
|
|
|
>{{ persistentCount }} persistent</span
|
|
|
|
|
|
>
|
2025-12-17 14:26:07 +01:00
|
|
|
|
</div>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
<div class="header-actions">
|
|
|
|
|
|
<button @click="refreshPlaylists" :disabled="listLoading">
|
|
|
|
|
|
{{ listLoading ? "Refreshing..." : "Refresh" }}
|
|
|
|
|
|
</button>
|
2025-12-17 14:26:07 +01:00
|
|
|
|
</div>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
</div>
|
2025-12-17 14:26:07 +01:00
|
|
|
|
|
2026-01-04 18:54:31 +01:00
|
|
|
|
<div class="panels">
|
|
|
|
|
|
<section class="side-panel">
|
|
|
|
|
|
<div class="section-card">
|
|
|
|
|
|
<h3>➕ Create Playlist</h3>
|
|
|
|
|
|
<form @submit.prevent="handleCreatePlaylist">
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>ID</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model.trim="createForm.id"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="unique id"
|
|
|
|
|
|
required
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Title</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model.trim="createForm.title"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="Friendly label"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Role</span>
|
|
|
|
|
|
<select v-model="createForm.role">
|
|
|
|
|
|
<option
|
|
|
|
|
|
v-for="option in roleOptions"
|
|
|
|
|
|
:key="option.value"
|
|
|
|
|
|
:value="option.value"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ option.label }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label
|
|
|
|
|
|
v-if="createForm.role === 'custom'"
|
|
|
|
|
|
class="field"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>Custom role</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model.trim="createForm.customRole"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="ex: qobuz/discover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Cover PK (optional)</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model.trim="createForm.coverPk"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="cover cache pk"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="cover-preview-row"
|
|
|
|
|
|
v-if="createCoverPreview"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="cover-preview small">
|
|
|
|
|
|
<img
|
|
|
|
|
|
:src="createCoverPreview"
|
|
|
|
|
|
alt="Cover preview"
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span class="small muted"
|
|
|
|
|
|
>Preview sourced from /covers/image/{{
|
|
|
|
|
|
createForm.coverPk
|
|
|
|
|
|
}}</span
|
|
|
|
|
|
>
|
2025-12-17 15:25:02 +01:00
|
|
|
|
</div>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
<label class="checkbox">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
v-model="createForm.persistent"
|
|
|
|
|
|
/>
|
|
|
|
|
|
Persistent (stored in SQLite)
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Max size</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="createForm.maxSize"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
placeholder="Unlimited"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Default TTL (s)</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="createForm.defaultTtl"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
placeholder="Never expire"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
2025-12-17 15:25:02 +01:00
|
|
|
|
</div>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
<div class="form-messages">
|
|
|
|
|
|
<p v-if="createState.error" class="error">
|
|
|
|
|
|
❌ {{ createState.error }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p v-if="createState.success" class="success">
|
|
|
|
|
|
✅ {{ createState.success }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
class="primary"
|
|
|
|
|
|
:disabled="createState.busy"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
createState.busy
|
|
|
|
|
|
? "Creating..."
|
|
|
|
|
|
: "Create playlist"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section-card list-section">
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h3>📚 Playlists</h3>
|
|
|
|
|
|
<span v-if="listError" class="error small"
|
|
|
|
|
|
>❌ {{ listError }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="listLoading && playlists.length === 0"
|
|
|
|
|
|
class="empty-state"
|
|
|
|
|
|
>
|
|
|
|
|
|
Loading playlists...
|
2025-12-17 15:25:02 +01:00
|
|
|
|
</div>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
<div v-else-if="playlists.length === 0" class="empty-state">
|
|
|
|
|
|
No playlists yet. Create one above.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else class="playlist-list">
|
|
|
|
|
|
<article
|
|
|
|
|
|
v-for="playlist in sortedPlaylists"
|
|
|
|
|
|
:key="playlist.id"
|
|
|
|
|
|
:class="[
|
|
|
|
|
|
'playlist-card',
|
|
|
|
|
|
{
|
|
|
|
|
|
selected:
|
|
|
|
|
|
playlist.id === selectedPlaylistId,
|
|
|
|
|
|
},
|
|
|
|
|
|
]"
|
|
|
|
|
|
@click="selectedPlaylistId = playlist.id"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="card-title">
|
|
|
|
|
|
<strong>{{
|
|
|
|
|
|
playlist.title || playlist.id
|
|
|
|
|
|
}}</strong>
|
|
|
|
|
|
<small>#{{ playlist.id }}</small>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card-metrics">
|
|
|
|
|
|
<span>{{ playlist.track_count }} tracks</span>
|
|
|
|
|
|
<span>{{
|
|
|
|
|
|
formatRoleLabel(playlist.role)
|
|
|
|
|
|
}}</span>
|
|
|
|
|
|
<span v-if="playlist.persistent" class="pill"
|
|
|
|
|
|
>Persistent</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<span v-else class="pill transient"
|
|
|
|
|
|
>Transient</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-if="playlist.artist" class="card-artist">
|
|
|
|
|
|
<span class="artist-label"
|
|
|
|
|
|
>🎤 {{ playlist.artist }}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card-meta">
|
|
|
|
|
|
<span
|
|
|
|
|
|
>Max:
|
|
|
|
|
|
{{
|
|
|
|
|
|
formatCapacity(playlist.max_size)
|
|
|
|
|
|
}}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<span
|
|
|
|
|
|
>TTL:
|
|
|
|
|
|
{{
|
|
|
|
|
|
formatTtl(playlist.default_ttl_secs)
|
|
|
|
|
|
}}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card-meta">
|
|
|
|
|
|
<span
|
|
|
|
|
|
>Updated
|
|
|
|
|
|
{{
|
|
|
|
|
|
formatRelativeDate(playlist.last_change)
|
|
|
|
|
|
}}</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-secondary"
|
|
|
|
|
|
@click.stop="
|
|
|
|
|
|
handleFlushPlaylist(playlist.id)
|
|
|
|
|
|
"
|
|
|
|
|
|
:disabled="
|
|
|
|
|
|
flushState.busy &&
|
|
|
|
|
|
flushState.targetId === playlist.id
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
flushState.busy &&
|
|
|
|
|
|
flushState.targetId === playlist.id
|
|
|
|
|
|
? "Flushing..."
|
|
|
|
|
|
: "Flush"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-danger"
|
|
|
|
|
|
@click.stop="
|
|
|
|
|
|
handleDeletePlaylist(playlist.id)
|
|
|
|
|
|
"
|
|
|
|
|
|
:disabled="
|
|
|
|
|
|
deleteState.busy &&
|
|
|
|
|
|
deleteState.targetId === playlist.id
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
deleteState.busy &&
|
|
|
|
|
|
deleteState.targetId === playlist.id
|
|
|
|
|
|
? "Deleting..."
|
|
|
|
|
|
: "Delete"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
2025-12-17 15:25:02 +01:00
|
|
|
|
</div>
|
2025-12-17 14:26:07 +01:00
|
|
|
|
</div>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="detail-panel">
|
|
|
|
|
|
<div class="section-card detail-card">
|
|
|
|
|
|
<template v-if="!selectedSummary">
|
|
|
|
|
|
<div class="empty-state">
|
|
|
|
|
|
Select a playlist to inspect details.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<div class="detail-header">
|
|
|
|
|
|
<div class="detail-cover">
|
|
|
|
|
|
<img
|
|
|
|
|
|
v-if="summaryCoverUrl"
|
|
|
|
|
|
:src="summaryCoverUrl"
|
|
|
|
|
|
alt="Playlist cover"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div v-else class="cover-placeholder large">
|
|
|
|
|
|
🎵
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="detail-header-main">
|
|
|
|
|
|
<h3>
|
|
|
|
|
|
{{ detailTitle }}
|
|
|
|
|
|
<span class="pill">{{
|
|
|
|
|
|
selectedSummary
|
|
|
|
|
|
? formatRoleLabel(
|
|
|
|
|
|
selectedSummary.role,
|
|
|
|
|
|
)
|
|
|
|
|
|
: "Unknown"
|
|
|
|
|
|
}}</span>
|
|
|
|
|
|
</h3>
|
|
|
|
|
|
<p class="detail-id">
|
|
|
|
|
|
{{ detailId ? `#${detailId}` : "—" }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="detail-header-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
@click="loadSelectedPlaylist(false)"
|
|
|
|
|
|
:disabled="detailLoading"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
detailLoading
|
|
|
|
|
|
? "Reloading..."
|
|
|
|
|
|
: "Reload"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="detail-stats">
|
|
|
|
|
|
<span
|
|
|
|
|
|
><strong>{{
|
|
|
|
|
|
selectedSummary
|
|
|
|
|
|
? selectedSummary.track_count
|
|
|
|
|
|
: 0
|
|
|
|
|
|
}}</strong>
|
|
|
|
|
|
tracks</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>{{
|
|
|
|
|
|
formatCapacity(
|
|
|
|
|
|
selectedSummary
|
|
|
|
|
|
? selectedSummary.max_size
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
)
|
|
|
|
|
|
}}</strong>
|
|
|
|
|
|
capacity
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
<strong>{{
|
|
|
|
|
|
formatTtl(
|
|
|
|
|
|
selectedSummary
|
|
|
|
|
|
? selectedSummary.default_ttl_secs
|
|
|
|
|
|
: undefined,
|
|
|
|
|
|
)
|
|
|
|
|
|
}}</strong>
|
|
|
|
|
|
TTL
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span>
|
|
|
|
|
|
Last change:
|
|
|
|
|
|
{{
|
|
|
|
|
|
selectedSummary
|
|
|
|
|
|
? formatRelativeDate(
|
|
|
|
|
|
selectedSummary.last_change,
|
|
|
|
|
|
)
|
|
|
|
|
|
: "—"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span class="cover-stat">
|
|
|
|
|
|
Cover:
|
|
|
|
|
|
<template v-if="selectedSummary?.cover_pk">
|
|
|
|
|
|
<code>{{ selectedSummary.cover_pk }}</code>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-secondary btn-compact"
|
|
|
|
|
|
@click="
|
|
|
|
|
|
selectedSummary?.cover_pk &&
|
|
|
|
|
|
copyPk(selectedSummary.cover_pk)
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
Copy
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
|
<span>None</span>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="detail-messages">
|
|
|
|
|
|
<p v-if="detailError" class="error">
|
|
|
|
|
|
❌ {{ detailError }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p v-if="updateState.message" class="success">
|
|
|
|
|
|
✅ {{ updateState.message }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<p v-if="addTracksState.success" class="success">
|
|
|
|
|
|
✅ {{ addTracksState.success }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-if="detailLoading && !selectedPlaylist"
|
|
|
|
|
|
class="empty-state"
|
|
|
|
|
|
>
|
|
|
|
|
|
Loading playlist details...
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div v-else-if="selectedPlaylist">
|
|
|
|
|
|
<div class="section-subcard">
|
|
|
|
|
|
<h4>Metadata & Config</h4>
|
|
|
|
|
|
<form @submit.prevent="handleUpdatePlaylist">
|
|
|
|
|
|
<div class="field-grid">
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Title</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model.trim="updateForm.title"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Role</span>
|
|
|
|
|
|
<select v-model="updateForm.role">
|
|
|
|
|
|
<option
|
|
|
|
|
|
v-for="option in roleOptions"
|
|
|
|
|
|
:key="option.value"
|
|
|
|
|
|
:value="option.value"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ option.label }}
|
|
|
|
|
|
</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label
|
|
|
|
|
|
v-if="updateForm.role === 'custom'"
|
|
|
|
|
|
class="field"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span>Custom role</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model.trim="
|
|
|
|
|
|
updateForm.customRole
|
|
|
|
|
|
"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Max size</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="updateForm.maxSize"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
placeholder="Unlimited"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Default TTL (s)</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="updateForm.defaultTtl"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
placeholder="Never expire"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Cover PK</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="updateForm.coverPk"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="cover cache pk (leave blank to clear)"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div class="cover-preview-row">
|
|
|
|
|
|
<div class="cover-preview">
|
|
|
|
|
|
<img
|
|
|
|
|
|
v-if="updateCoverPreview"
|
|
|
|
|
|
:src="updateCoverPreview"
|
|
|
|
|
|
alt="Cover preview"
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="cover-placeholder small"
|
|
|
|
|
|
>
|
|
|
|
|
|
No cover
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
class="btn-secondary btn-compact"
|
|
|
|
|
|
@click="updateForm.coverPk = ''"
|
|
|
|
|
|
>
|
|
|
|
|
|
Clear
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-messages">
|
|
|
|
|
|
<p
|
|
|
|
|
|
v-if="updateState.error"
|
|
|
|
|
|
class="error"
|
|
|
|
|
|
>
|
|
|
|
|
|
❌ {{ updateState.error }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
class="primary"
|
|
|
|
|
|
:disabled="updateState.busy"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
updateState.busy
|
|
|
|
|
|
? "Saving..."
|
|
|
|
|
|
: "Apply changes"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section-subcard">
|
|
|
|
|
|
<h4>Add Tracks</h4>
|
|
|
|
|
|
<form @submit.prevent="handleAddTracks">
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span
|
|
|
|
|
|
>Cache PKs (comma or newline
|
|
|
|
|
|
separated)</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<textarea
|
|
|
|
|
|
v-model.trim="addTracksForm.pks"
|
|
|
|
|
|
rows="3"
|
|
|
|
|
|
placeholder="pk1, pk2, L:lazyPk"
|
|
|
|
|
|
></textarea>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div class="field-group">
|
|
|
|
|
|
<label class="field">
|
|
|
|
|
|
<span>Force TTL (s)</span>
|
|
|
|
|
|
<input
|
|
|
|
|
|
v-model="addTracksForm.ttl"
|
|
|
|
|
|
type="number"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
placeholder="Default"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label class="checkbox">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
v-model="addTracksForm.lazy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
Insert as lazy references
|
|
|
|
|
|
</label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-messages">
|
|
|
|
|
|
<p
|
|
|
|
|
|
v-if="addTracksState.error"
|
|
|
|
|
|
class="error"
|
|
|
|
|
|
>
|
|
|
|
|
|
❌ {{ addTracksState.error }}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
class="primary"
|
|
|
|
|
|
:disabled="addTracksState.busy"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
addTracksState.busy
|
|
|
|
|
|
? "Adding..."
|
|
|
|
|
|
: "Add tracks"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="section-subcard tracks">
|
|
|
|
|
|
<div class="section-header">
|
|
|
|
|
|
<h4>Tracks</h4>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
selectedPlaylist.tracks.length === 0
|
|
|
|
|
|
"
|
|
|
|
|
|
class="small"
|
|
|
|
|
|
>Empty playlist</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<span v-else class="small">
|
|
|
|
|
|
{{ selectedPlaylist.tracks.length }}
|
|
|
|
|
|
entries
|
|
|
|
|
|
<span v-if="lazyTracksCount > 0"
|
|
|
|
|
|
>· {{ lazyTracksCount }} lazy</span
|
|
|
|
|
|
>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
2026-04-06 17:48:30 +02:00
|
|
|
|
<!-- Pagination controls -->
|
|
|
|
|
|
<div v-if="totalPages > 1" class="pagination-controls">
|
|
|
|
|
|
<button @click="prevPage" :disabled="currentPage === 0">
|
|
|
|
|
|
← Previous
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<span class="page-info">
|
|
|
|
|
|
Page {{ currentPage + 1 }} of {{ totalPages }}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
|
|
|
|
|
|
Next →
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
<div
|
|
|
|
|
|
class="track-grid"
|
2026-04-06 17:48:30 +02:00
|
|
|
|
v-if="paginatedTracks.length > 0"
|
2026-01-04 18:54:31 +01:00
|
|
|
|
>
|
|
|
|
|
|
<article
|
2026-04-06 17:48:30 +02:00
|
|
|
|
v-for="track in paginatedTracks"
|
2026-01-04 18:54:31 +01:00
|
|
|
|
:key="`${track.cache_pk}-${track.added_at}`"
|
|
|
|
|
|
class="track-card"
|
|
|
|
|
|
:class="{ lazy: isLazyTrack(track) }"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div class="cover-wrapper">
|
|
|
|
|
|
<img
|
|
|
|
|
|
v-if="trackCoverUrl(track, 200)"
|
|
|
|
|
|
:src="trackCoverUrl(track, 200)"
|
|
|
|
|
|
:alt="trackTitle(track)"
|
|
|
|
|
|
loading="lazy"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div
|
|
|
|
|
|
v-else
|
|
|
|
|
|
class="cover-placeholder"
|
|
|
|
|
|
>
|
|
|
|
|
|
🎵
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
isLazyTrack(track) ||
|
|
|
|
|
|
trackLazyReference(track)
|
|
|
|
|
|
"
|
|
|
|
|
|
class="cover-lazy-pill"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
isLazyTrack(track)
|
|
|
|
|
|
? "Lazy entry"
|
|
|
|
|
|
: "Lazy ref"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="track-info">
|
|
|
|
|
|
<div class="track-title">
|
|
|
|
|
|
{{ trackTitle(track) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="track-artist"
|
|
|
|
|
|
v-if="trackArtist(track)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ trackArtist(track) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="track-album"
|
|
|
|
|
|
v-if="trackAlbum(track)"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ trackAlbum(track) }}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="track-meta">
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
trackDurationLabel(
|
|
|
|
|
|
track,
|
|
|
|
|
|
)
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
trackDurationLabel(
|
|
|
|
|
|
track,
|
|
|
|
|
|
)
|
|
|
|
|
|
}}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
trackSampleRateLabel(
|
|
|
|
|
|
track,
|
|
|
|
|
|
)
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
trackSampleRateLabel(
|
|
|
|
|
|
track,
|
|
|
|
|
|
)
|
|
|
|
|
|
}}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
trackBitrateLabel(track)
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
trackBitrateLabel(track)
|
|
|
|
|
|
}}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="pk-info">
|
|
|
|
|
|
<div class="pk-line">
|
|
|
|
|
|
<span class="label"
|
|
|
|
|
|
>PK</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<code>{{
|
|
|
|
|
|
track.cache_pk
|
|
|
|
|
|
}}</code>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
class="pk-line"
|
|
|
|
|
|
v-if="
|
|
|
|
|
|
trackLazyReference(
|
|
|
|
|
|
track,
|
|
|
|
|
|
)
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
<span class="label"
|
|
|
|
|
|
>Lazy</span
|
|
|
|
|
|
>
|
|
|
|
|
|
<code>{{
|
|
|
|
|
|
trackLazyReference(
|
|
|
|
|
|
track,
|
|
|
|
|
|
)
|
|
|
|
|
|
}}</code>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="added-info">
|
|
|
|
|
|
Added
|
|
|
|
|
|
{{
|
|
|
|
|
|
formatRelativeDate(
|
|
|
|
|
|
track.added_at,
|
|
|
|
|
|
)
|
|
|
|
|
|
}}
|
|
|
|
|
|
· TTL
|
|
|
|
|
|
{{
|
|
|
|
|
|
trackTtlLabel(
|
|
|
|
|
|
track.ttl_secs,
|
|
|
|
|
|
)
|
|
|
|
|
|
}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="track-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-secondary"
|
|
|
|
|
|
@click="copyPk(track.cache_pk)"
|
|
|
|
|
|
>
|
|
|
|
|
|
Copy PK
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
class="btn-danger"
|
|
|
|
|
|
@click="
|
|
|
|
|
|
handleRemoveTrack(
|
|
|
|
|
|
track.cache_pk,
|
|
|
|
|
|
)
|
|
|
|
|
|
"
|
|
|
|
|
|
:disabled="
|
|
|
|
|
|
isRemovingTrack(
|
|
|
|
|
|
track.cache_pk,
|
|
|
|
|
|
)
|
|
|
|
|
|
"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{
|
|
|
|
|
|
isRemovingTrack(
|
|
|
|
|
|
track.cache_pk,
|
|
|
|
|
|
)
|
|
|
|
|
|
? "..."
|
|
|
|
|
|
: "Remove"
|
|
|
|
|
|
}}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2025-12-17 14:26:07 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
2026-01-04 18:54:31 +01:00
|
|
|
|
import type {
|
|
|
|
|
|
PlaylistDetail,
|
|
|
|
|
|
PlaylistSummary,
|
|
|
|
|
|
PlaylistTrack,
|
|
|
|
|
|
} from "../services/playlists";
|
2025-12-17 14:26:07 +01:00
|
|
|
|
import {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
addTracksToPlaylist,
|
|
|
|
|
|
createPlaylist,
|
|
|
|
|
|
deletePlaylist,
|
|
|
|
|
|
flushPlaylist,
|
|
|
|
|
|
getPlaylistDetail,
|
|
|
|
|
|
listPlaylists,
|
|
|
|
|
|
removeTrackFromPlaylist,
|
|
|
|
|
|
updatePlaylist,
|
2025-12-17 14:26:07 +01:00
|
|
|
|
} from "../services/playlists";
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
import type { AudioCacheMetadata } from "../services/audioCache";
|
|
|
|
|
|
import {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
getCoverUrl as getAudioCoverUrl,
|
|
|
|
|
|
getDurationMs,
|
|
|
|
|
|
formatDuration,
|
|
|
|
|
|
formatBitrate,
|
|
|
|
|
|
formatSampleRate,
|
2025-12-17 15:25:02 +01:00
|
|
|
|
} from "../services/audioCache";
|
|
|
|
|
|
|
2025-12-17 14:26:07 +01:00
|
|
|
|
const playlists = ref<PlaylistSummary[]>([]);
|
|
|
|
|
|
const listLoading = ref(false);
|
|
|
|
|
|
const listError = ref("");
|
|
|
|
|
|
const selectedPlaylistId = ref<string | null>(null);
|
|
|
|
|
|
const selectedPlaylist = ref<PlaylistDetail | null>(null);
|
|
|
|
|
|
const detailLoading = ref(false);
|
|
|
|
|
|
const detailError = ref("");
|
|
|
|
|
|
|
|
|
|
|
|
const roleOptions = [
|
2026-01-04 18:54:31 +01:00
|
|
|
|
{ value: "user", label: "User" },
|
|
|
|
|
|
{ value: "album", label: "Album" },
|
|
|
|
|
|
{ value: "radio", label: "Radio" },
|
|
|
|
|
|
{ value: "source", label: "Source" },
|
|
|
|
|
|
{ value: "custom", label: "Custom..." },
|
2025-12-17 14:26:07 +01:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const createForm = reactive({
|
2026-01-04 18:54:31 +01:00
|
|
|
|
id: "",
|
|
|
|
|
|
title: "",
|
|
|
|
|
|
role: "user",
|
|
|
|
|
|
customRole: "",
|
|
|
|
|
|
coverPk: "",
|
|
|
|
|
|
persistent: true,
|
|
|
|
|
|
maxSize: "",
|
|
|
|
|
|
defaultTtl: "",
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const createState = reactive({
|
2026-01-04 18:54:31 +01:00
|
|
|
|
busy: false,
|
|
|
|
|
|
error: "",
|
|
|
|
|
|
success: "",
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const updateForm = reactive({
|
2026-01-04 18:54:31 +01:00
|
|
|
|
title: "",
|
|
|
|
|
|
role: "user",
|
|
|
|
|
|
customRole: "",
|
|
|
|
|
|
coverPk: "",
|
|
|
|
|
|
maxSize: "",
|
|
|
|
|
|
defaultTtl: "",
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const updateState = reactive({
|
2026-01-04 18:54:31 +01:00
|
|
|
|
busy: false,
|
|
|
|
|
|
error: "",
|
|
|
|
|
|
message: "",
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const addTracksForm = reactive({
|
2026-01-04 18:54:31 +01:00
|
|
|
|
pks: "",
|
|
|
|
|
|
ttl: "",
|
|
|
|
|
|
lazy: false,
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
const addTracksState = reactive({
|
2026-01-04 18:54:31 +01:00
|
|
|
|
busy: false,
|
|
|
|
|
|
error: "",
|
|
|
|
|
|
success: "",
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const flushState = reactive({
|
2026-01-04 18:54:31 +01:00
|
|
|
|
busy: false,
|
|
|
|
|
|
targetId: "",
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const deleteState = reactive({
|
2026-01-04 18:54:31 +01:00
|
|
|
|
busy: false,
|
|
|
|
|
|
targetId: "",
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const removingTracks = ref(new Set<string>());
|
|
|
|
|
|
|
|
|
|
|
|
const totalTrackCount = computed(() =>
|
2026-01-04 18:54:31 +01:00
|
|
|
|
playlists.value.reduce((sum, item) => sum + item.track_count, 0),
|
|
|
|
|
|
);
|
|
|
|
|
|
const persistentCount = computed(
|
|
|
|
|
|
() => playlists.value.filter((p) => p.persistent).length,
|
2025-12-17 14:26:07 +01:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedSummary = computed(() => {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const detailSummary = selectedPlaylist.value?.summary;
|
|
|
|
|
|
if (
|
|
|
|
|
|
detailSummary &&
|
|
|
|
|
|
(!selectedPlaylistId.value ||
|
|
|
|
|
|
detailSummary.id === selectedPlaylistId.value)
|
|
|
|
|
|
) {
|
|
|
|
|
|
return detailSummary;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
playlists.value.find((p) => p.id === selectedPlaylistId.value) ??
|
|
|
|
|
|
detailSummary ??
|
|
|
|
|
|
null
|
|
|
|
|
|
);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
const detailTitle = computed(() => {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const summary = selectedSummary.value;
|
|
|
|
|
|
if (!summary) {
|
|
|
|
|
|
return "Playlist";
|
|
|
|
|
|
}
|
|
|
|
|
|
return summary.title || summary.id || "Playlist";
|
2025-12-17 15:25:02 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const detailId = computed(() => selectedSummary.value?.id ?? "");
|
|
|
|
|
|
|
|
|
|
|
|
const summaryCoverUrl = computed(() => {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const summary = selectedSummary.value;
|
|
|
|
|
|
if (!summary) return undefined;
|
|
|
|
|
|
if (summary.cover_url && summary.cover_url.length > 0) {
|
|
|
|
|
|
return summary.cover_url;
|
|
|
|
|
|
}
|
|
|
|
|
|
return coverUrlFromPk(summary.cover_pk);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-12-17 14:26:07 +01:00
|
|
|
|
const sortedPlaylists = computed(() => {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
return [...playlists.value].sort(
|
|
|
|
|
|
(a, b) =>
|
|
|
|
|
|
new Date(b.last_change).getTime() -
|
|
|
|
|
|
new Date(a.last_change).getTime(),
|
|
|
|
|
|
);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-04-06 17:48:30 +02:00
|
|
|
|
const PAGE_SIZE = 100;
|
|
|
|
|
|
const currentPage = ref(0);
|
|
|
|
|
|
const sortedTracksCache = new Map<string, PlaylistTrack[]>();
|
|
|
|
|
|
|
2025-12-17 14:26:07 +01:00
|
|
|
|
const sortedTracks = computed(() => {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const detail = selectedPlaylist.value;
|
|
|
|
|
|
if (!detail) return [];
|
2026-04-06 17:48:30 +02:00
|
|
|
|
const playlistId = detail.summary.id;
|
|
|
|
|
|
const cached = sortedTracksCache.get(playlistId);
|
|
|
|
|
|
if (cached && cached.length === detail.tracks.length) return cached;
|
|
|
|
|
|
const sorted = [...detail.tracks].sort(
|
2026-01-04 18:54:31 +01:00
|
|
|
|
(a, b) =>
|
|
|
|
|
|
new Date(b.added_at).getTime() - new Date(a.added_at).getTime(),
|
|
|
|
|
|
);
|
2026-04-06 17:48:30 +02:00
|
|
|
|
sortedTracksCache.set(playlistId, sorted);
|
|
|
|
|
|
return sorted;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const paginatedTracks = computed(() =>
|
|
|
|
|
|
sortedTracks.value.slice(
|
|
|
|
|
|
currentPage.value * PAGE_SIZE,
|
|
|
|
|
|
(currentPage.value + 1) * PAGE_SIZE
|
|
|
|
|
|
)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const totalPages = computed(() => Math.ceil(sortedTracks.value.length / PAGE_SIZE));
|
|
|
|
|
|
|
|
|
|
|
|
function nextPage() {
|
|
|
|
|
|
if (currentPage.value < totalPages.value - 1) {
|
|
|
|
|
|
currentPage.value++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function prevPage() {
|
|
|
|
|
|
if (currentPage.value > 0) {
|
|
|
|
|
|
currentPage.value--;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(sortedTracks, () => {
|
|
|
|
|
|
currentPage.value = 0;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const lazyTracksCount = computed(() =>
|
2026-04-06 17:48:30 +02:00
|
|
|
|
sortedTracks.value.filter((track) => isLazyTrack(track)).length
|
2025-12-17 14:26:07 +01:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const updateCoverPreview = computed(
|
|
|
|
|
|
() => coverUrlFromPk(updateForm.coverPk) ?? summaryCoverUrl.value,
|
|
|
|
|
|
);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
const createCoverPreview = computed(() => coverUrlFromPk(createForm.coverPk));
|
|
|
|
|
|
|
2025-12-17 14:26:07 +01:00
|
|
|
|
function formatRelativeDate(dateString: string) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
|
if (Number.isNaN(date.getTime())) return dateString;
|
|
|
|
|
|
const diff = Date.now() - date.getTime();
|
|
|
|
|
|
const minutes = Math.floor(diff / (1000 * 60));
|
|
|
|
|
|
if (minutes < 1) return "Just now";
|
|
|
|
|
|
if (minutes < 60) return `${minutes} min ago`;
|
|
|
|
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
|
|
if (hours < 24) return `${hours} h ago`;
|
|
|
|
|
|
const days = Math.floor(hours / 24);
|
|
|
|
|
|
if (days === 1) return "Yesterday";
|
|
|
|
|
|
if (days < 7) return `${days} days ago`;
|
|
|
|
|
|
return date.toLocaleString();
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatRoleLabel(role: string) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (!role) return "Unknown";
|
|
|
|
|
|
return role.charAt(0).toUpperCase() + role.slice(1);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatCapacity(capacity?: number | null) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (capacity === null || capacity === undefined) return "∞";
|
|
|
|
|
|
return capacity.toString();
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function formatTtl(ttl?: number | null) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (ttl === null || ttl === undefined) return "∞";
|
|
|
|
|
|
if (ttl < 60) return `${ttl}s`;
|
|
|
|
|
|
const minutes = Math.round(ttl / 60);
|
|
|
|
|
|
if (minutes < 60) return `${minutes}m`;
|
|
|
|
|
|
const hours = Math.round(minutes / 60);
|
|
|
|
|
|
return `${hours}h`;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
function coverUrlFromPk(pk?: string | null, size = 256): string | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (!pk) return undefined;
|
|
|
|
|
|
const trimmed = pk.trim();
|
|
|
|
|
|
if (!trimmed) return undefined;
|
|
|
|
|
|
return `/covers/image/${trimmed}/${size}`;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 14:26:07 +01:00
|
|
|
|
function trackTtlLabel(ttl?: number | null) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (ttl === null || ttl === undefined) return "inherit";
|
|
|
|
|
|
if (ttl === 0) return "Expires now";
|
|
|
|
|
|
if (ttl < 60) return `${ttl}s`;
|
|
|
|
|
|
return `${Math.round(ttl / 60)}m`;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function copyPk(pk: string) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
navigator.clipboard.writeText(pk);
|
|
|
|
|
|
alert(`Copied ${pk}`);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isRemovingTrack(pk: string) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
return removingTracks.value.has(pk);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function refreshPlaylists() {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
listLoading.value = true;
|
|
|
|
|
|
listError.value = "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const data = await listPlaylists();
|
|
|
|
|
|
playlists.value = data;
|
|
|
|
|
|
if (data.length === 0) {
|
|
|
|
|
|
selectedPlaylistId.value = null;
|
|
|
|
|
|
selectedPlaylist.value = null;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const first = data[0];
|
|
|
|
|
|
if (!first) {
|
|
|
|
|
|
selectedPlaylistId.value = null;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
selectedPlaylistId.value &&
|
|
|
|
|
|
!data.some(
|
|
|
|
|
|
(playlist) => playlist.id === selectedPlaylistId.value,
|
|
|
|
|
|
)
|
|
|
|
|
|
) {
|
|
|
|
|
|
selectedPlaylistId.value = first.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!selectedPlaylistId.value) {
|
|
|
|
|
|
selectedPlaylistId.value = first.id;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
listError.value = (error as Error).message ?? String(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
listLoading.value = false;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
watch(
|
2026-01-04 18:54:31 +01:00
|
|
|
|
() => selectedPlaylistId.value,
|
|
|
|
|
|
(id, previous) => {
|
|
|
|
|
|
if (id) {
|
|
|
|
|
|
if (previous !== id) {
|
|
|
|
|
|
selectedPlaylist.value = null;
|
|
|
|
|
|
detailError.value = "";
|
|
|
|
|
|
}
|
|
|
|
|
|
loadSelectedPlaylist(previous !== id);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
selectedPlaylist.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2025-12-17 14:26:07 +01:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
async function loadSelectedPlaylist(syncForms = true) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const id = selectedPlaylistId.value;
|
|
|
|
|
|
if (!id) return;
|
|
|
|
|
|
detailLoading.value = true;
|
|
|
|
|
|
detailError.value = "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await getPlaylistDetail(id);
|
|
|
|
|
|
selectedPlaylist.value = detail;
|
|
|
|
|
|
if (syncForms) {
|
|
|
|
|
|
syncUpdateForm(detail);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
detailError.value = (error as Error).message ?? String(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
detailLoading.value = false;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
function trackMetadata(track: PlaylistTrack): AudioCacheMetadata | null {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
return track.metadata ?? null;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackTitle(track: PlaylistTrack): string {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const metadata = trackMetadata(track);
|
|
|
|
|
|
return metadata?.title || track.cache_pk;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackArtist(track: PlaylistTrack): string | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const metadata = trackMetadata(track);
|
|
|
|
|
|
return metadata?.artist || undefined;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackAlbum(track: PlaylistTrack): string | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const metadata = trackMetadata(track);
|
|
|
|
|
|
return metadata?.album || undefined;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackDurationLabel(track: PlaylistTrack): string | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const duration = getDurationMs(trackMetadata(track));
|
|
|
|
|
|
if (duration === undefined) return undefined;
|
|
|
|
|
|
return formatDuration(duration);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackSampleRateLabel(track: PlaylistTrack): string | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const metadata = trackMetadata(track);
|
|
|
|
|
|
if (!metadata?.sample_rate) return undefined;
|
|
|
|
|
|
return formatSampleRate(metadata.sample_rate);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackBitrateLabel(track: PlaylistTrack): string | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const metadata = trackMetadata(track);
|
|
|
|
|
|
if (!metadata?.bitrate) return undefined;
|
|
|
|
|
|
return formatBitrate(metadata.bitrate);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackCoverUrl(track: PlaylistTrack, size = 200): string | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (track.cover_url) {
|
|
|
|
|
|
if (size && track.cover_source === "cover_pk") {
|
|
|
|
|
|
const [rawBase, query] = track.cover_url.split("?");
|
|
|
|
|
|
const base = rawBase ?? track.cover_url;
|
|
|
|
|
|
if (base) {
|
|
|
|
|
|
const segments = base.split("/");
|
|
|
|
|
|
const lastIndex = segments.length - 1;
|
|
|
|
|
|
if (lastIndex >= 0) {
|
|
|
|
|
|
const last = segments[lastIndex];
|
|
|
|
|
|
if (last && /^\d+$/.test(last)) {
|
|
|
|
|
|
segments[lastIndex] = String(size);
|
|
|
|
|
|
const rebuilt = segments.join("/");
|
|
|
|
|
|
return query ? `${rebuilt}?${query}` : rebuilt;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
2026-01-04 18:54:31 +01:00
|
|
|
|
return track.cover_url;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const metadata = trackMetadata(track);
|
|
|
|
|
|
if (!metadata) return undefined;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return getAudioCoverUrl(metadata, size) || undefined;
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return undefined;
|
|
|
|
|
|
}
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function trackLazyReference(track: PlaylistTrack): string | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
return track.lazy_pk ?? undefined;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isLazyPk(pk: string): boolean {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
return pk.startsWith("L:");
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function isLazyTrack(track: PlaylistTrack): boolean {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
return isLazyPk(track.cache_pk);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 14:26:07 +01:00
|
|
|
|
function syncUpdateForm(detail: PlaylistDetail | null) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (!detail) return;
|
|
|
|
|
|
updateForm.title = detail.summary.title || "";
|
|
|
|
|
|
if (roleOptions.some((opt) => opt.value === detail.summary.role)) {
|
|
|
|
|
|
updateForm.role = detail.summary.role;
|
|
|
|
|
|
updateForm.customRole = "";
|
|
|
|
|
|
} else {
|
|
|
|
|
|
updateForm.role = "custom";
|
|
|
|
|
|
updateForm.customRole = detail.summary.role;
|
|
|
|
|
|
}
|
|
|
|
|
|
updateForm.coverPk = detail.summary.cover_pk ?? "";
|
|
|
|
|
|
updateForm.maxSize =
|
|
|
|
|
|
detail.summary.max_size === null ||
|
|
|
|
|
|
detail.summary.max_size === undefined
|
|
|
|
|
|
? ""
|
|
|
|
|
|
: String(detail.summary.max_size);
|
|
|
|
|
|
updateForm.defaultTtl =
|
|
|
|
|
|
detail.summary.default_ttl_secs === null ||
|
|
|
|
|
|
detail.summary.default_ttl_secs === undefined
|
|
|
|
|
|
? ""
|
|
|
|
|
|
: String(detail.summary.default_ttl_secs);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resetCreateForm() {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
createForm.id = "";
|
|
|
|
|
|
createForm.title = "";
|
|
|
|
|
|
createForm.role = "user";
|
|
|
|
|
|
createForm.customRole = "";
|
|
|
|
|
|
createForm.coverPk = "";
|
|
|
|
|
|
createForm.persistent = true;
|
|
|
|
|
|
createForm.maxSize = "";
|
|
|
|
|
|
createForm.defaultTtl = "";
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function parseOptionalNumber(value: string): number | undefined {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
|
if (!trimmed) return undefined;
|
|
|
|
|
|
const parsed = Number(trimmed);
|
|
|
|
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
|
|
|
|
throw new Error("Value must be a positive number");
|
|
|
|
|
|
}
|
|
|
|
|
|
return Math.floor(parsed);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 18:54:31 +01:00
|
|
|
|
function resolveRoleValue(
|
|
|
|
|
|
roleValue: string,
|
|
|
|
|
|
customValue: string,
|
|
|
|
|
|
): string | undefined {
|
|
|
|
|
|
if (roleValue === "custom") {
|
|
|
|
|
|
const trimmed = customValue.trim();
|
|
|
|
|
|
return trimmed || undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
return roleValue || undefined;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleCreatePlaylist() {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
createState.error = "";
|
|
|
|
|
|
createState.success = "";
|
|
|
|
|
|
if (!createForm.id.trim()) {
|
|
|
|
|
|
createState.error = "ID is required";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (createForm.role === "custom" && !createForm.customRole.trim()) {
|
|
|
|
|
|
createState.error = "Provide a custom role name";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let maxSize: number | undefined;
|
|
|
|
|
|
let defaultTtl: number | undefined;
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (createForm.maxSize.trim()) {
|
|
|
|
|
|
maxSize = parseOptionalNumber(createForm.maxSize) ?? undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (createForm.defaultTtl.trim()) {
|
|
|
|
|
|
defaultTtl =
|
|
|
|
|
|
parseOptionalNumber(createForm.defaultTtl) ?? undefined;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
createState.error = (error as Error).message;
|
|
|
|
|
|
return;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
2026-01-04 18:54:31 +01:00
|
|
|
|
|
|
|
|
|
|
const roleValue = resolveRoleValue(createForm.role, createForm.customRole);
|
|
|
|
|
|
const coverPk = createForm.coverPk.trim();
|
|
|
|
|
|
|
|
|
|
|
|
createState.busy = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await createPlaylist({
|
|
|
|
|
|
id: createForm.id.trim(),
|
|
|
|
|
|
title: createForm.title || undefined,
|
|
|
|
|
|
role: roleValue,
|
|
|
|
|
|
cover_pk: coverPk || undefined,
|
|
|
|
|
|
persistent: createForm.persistent,
|
|
|
|
|
|
max_size: maxSize,
|
|
|
|
|
|
default_ttl_secs: defaultTtl,
|
|
|
|
|
|
});
|
|
|
|
|
|
createState.success = `Playlist ${detail.summary.id} created`;
|
|
|
|
|
|
resetCreateForm();
|
|
|
|
|
|
await refreshPlaylists();
|
|
|
|
|
|
selectedPlaylistId.value = detail.summary.id;
|
|
|
|
|
|
selectedPlaylist.value = detail;
|
|
|
|
|
|
syncUpdateForm(detail);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
createState.error = (error as Error).message ?? String(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
createState.busy = false;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleUpdatePlaylist() {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (!selectedSummary.value) return;
|
|
|
|
|
|
updateState.error = "";
|
|
|
|
|
|
updateState.message = "";
|
|
|
|
|
|
const summary = selectedSummary.value;
|
|
|
|
|
|
|
|
|
|
|
|
let maxSizePayload: number | null | undefined;
|
|
|
|
|
|
let defaultTtlPayload: number | null | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (updateForm.maxSize.trim() === "") {
|
|
|
|
|
|
maxSizePayload =
|
|
|
|
|
|
summary.max_size === null || summary.max_size === undefined
|
|
|
|
|
|
? undefined
|
|
|
|
|
|
: null;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const value = parseOptionalNumber(updateForm.maxSize);
|
|
|
|
|
|
if (value !== summary.max_size) {
|
|
|
|
|
|
maxSizePayload = value ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (updateForm.defaultTtl.trim() === "") {
|
|
|
|
|
|
defaultTtlPayload =
|
|
|
|
|
|
summary.default_ttl_secs === null ||
|
|
|
|
|
|
summary.default_ttl_secs === undefined
|
|
|
|
|
|
? undefined
|
|
|
|
|
|
: null;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const ttlValue = parseOptionalNumber(updateForm.defaultTtl);
|
|
|
|
|
|
if (ttlValue !== summary.default_ttl_secs) {
|
|
|
|
|
|
defaultTtlPayload = ttlValue ?? null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
updateState.error = (error as Error).message;
|
|
|
|
|
|
return;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-01-04 18:54:31 +01:00
|
|
|
|
const payload: Record<string, unknown> = {};
|
|
|
|
|
|
const trimmedTitle = updateForm.title.trim();
|
|
|
|
|
|
if (trimmedTitle !== summary.title) {
|
|
|
|
|
|
payload.title = trimmedTitle;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
2026-01-04 18:54:31 +01:00
|
|
|
|
|
|
|
|
|
|
const resolvedRole = resolveRoleValue(
|
|
|
|
|
|
updateForm.role,
|
|
|
|
|
|
updateForm.customRole,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (updateForm.role === "custom" && !updateForm.customRole.trim()) {
|
|
|
|
|
|
updateState.error = "Provide a custom role name";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (resolvedRole && resolvedRole !== summary.role) {
|
|
|
|
|
|
payload.role = resolvedRole;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (maxSizePayload !== undefined) {
|
|
|
|
|
|
payload.max_size = maxSizePayload;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (defaultTtlPayload !== undefined) {
|
|
|
|
|
|
payload.default_ttl_secs = defaultTtlPayload;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const trimmedCoverPk = updateForm.coverPk.trim();
|
|
|
|
|
|
const currentCoverPk = summary.cover_pk ?? "";
|
|
|
|
|
|
if (trimmedCoverPk === "") {
|
|
|
|
|
|
if (currentCoverPk !== "") {
|
|
|
|
|
|
payload.cover_pk = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (trimmedCoverPk !== currentCoverPk) {
|
|
|
|
|
|
payload.cover_pk = trimmedCoverPk;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (Object.keys(payload).length === 0) {
|
|
|
|
|
|
updateState.message = "No changes to apply";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
updateState.busy = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await updatePlaylist(selectedSummary.value.id, payload);
|
|
|
|
|
|
selectedPlaylist.value = detail;
|
|
|
|
|
|
updateState.message = "Playlist updated";
|
|
|
|
|
|
await refreshPlaylists();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
updateState.error = (error as Error).message ?? String(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
updateState.busy = false;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function extractPkList(raw: string) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
return raw
|
|
|
|
|
|
.split(/[\n,]+/)
|
|
|
|
|
|
.map((pk) => pk.trim())
|
|
|
|
|
|
.filter((pk) => pk.length > 0);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleAddTracks() {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (!selectedSummary.value) return;
|
|
|
|
|
|
addTracksState.error = "";
|
|
|
|
|
|
addTracksState.success = "";
|
|
|
|
|
|
|
|
|
|
|
|
const pkList = extractPkList(addTracksForm.pks);
|
|
|
|
|
|
if (pkList.length === 0) {
|
|
|
|
|
|
addTracksState.error = "Provide at least one PK";
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let ttlValue: number | undefined;
|
|
|
|
|
|
try {
|
|
|
|
|
|
ttlValue = parseOptionalNumber(addTracksForm.ttl);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
addTracksState.error = (error as Error).message;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addTracksState.busy = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await addTracksToPlaylist(selectedSummary.value.id, {
|
|
|
|
|
|
cache_pks: pkList,
|
|
|
|
|
|
ttl_secs: ttlValue,
|
|
|
|
|
|
lazy: addTracksForm.lazy,
|
|
|
|
|
|
});
|
|
|
|
|
|
selectedPlaylist.value = detail;
|
|
|
|
|
|
addTracksState.success = `${pkList.length} track(s) added`;
|
|
|
|
|
|
addTracksForm.pks = "";
|
|
|
|
|
|
addTracksForm.ttl = "";
|
|
|
|
|
|
addTracksForm.lazy = false;
|
|
|
|
|
|
await refreshPlaylists();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
addTracksState.error = (error as Error).message ?? String(error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
addTracksState.busy = false;
|
|
|
|
|
|
}
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleRemoveTrack(cachePk: string) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (!selectedSummary.value || !cachePk) return;
|
|
|
|
|
|
removingTracks.value.add(cachePk);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await removeTrackFromPlaylist(
|
|
|
|
|
|
selectedSummary.value.id,
|
|
|
|
|
|
cachePk,
|
|
|
|
|
|
);
|
|
|
|
|
|
selectedPlaylist.value = detail;
|
|
|
|
|
|
await refreshPlaylists();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert((error as Error).message ?? String(error));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
removingTracks.value.delete(cachePk);
|
|
|
|
|
|
}
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleFlushPlaylist(id: string) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
flushState.busy = true;
|
|
|
|
|
|
flushState.targetId = id;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const detail = await flushPlaylist(id);
|
|
|
|
|
|
if (selectedPlaylistId.value === id) {
|
|
|
|
|
|
selectedPlaylist.value = detail;
|
|
|
|
|
|
}
|
|
|
|
|
|
await refreshPlaylists();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert((error as Error).message ?? String(error));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
flushState.busy = false;
|
|
|
|
|
|
flushState.targetId = "";
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleDeletePlaylist(id: string) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
if (!confirm(`Delete playlist ${id}?`)) return;
|
|
|
|
|
|
deleteState.busy = true;
|
|
|
|
|
|
deleteState.targetId = id;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await deletePlaylist(id);
|
|
|
|
|
|
if (selectedPlaylistId.value === id) {
|
|
|
|
|
|
selectedPlaylistId.value = null;
|
|
|
|
|
|
selectedPlaylist.value = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
await refreshPlaylists();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
alert((error as Error).message ?? String(error));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
deleteState.busy = false;
|
|
|
|
|
|
deleteState.targetId = "";
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
refreshPlaylists();
|
2025-12-17 14:26:07 +01:00
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.playlist-manager {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
padding: var(--spacing-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
max-width: 1400px;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
box-sizing: border-box;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--color-text);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 768px) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
.playlist-manager {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
padding: var(--spacing-sm);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
}
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
justify-content: space-between;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: var(--spacing-md);
|
|
|
|
|
|
padding: var(--spacing-md);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.05);
|
|
|
|
|
|
margin-bottom: var(--spacing-md);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header h2 {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
margin: 0;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--color-primary);
|
|
|
|
|
|
font-size: var(--text-xl);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.subtitle {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
margin: 0.25rem 0 0;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
font-size: var(--text-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-stats {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
flex-wrap: wrap;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-stats span {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
padding: 0.25rem 0.75rem;
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-actions button {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
padding: 0.5rem 1rem;
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
border: none;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: var(--color-primary);
|
|
|
|
|
|
color: #fff;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
cursor: pointer;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
transition: opacity var(--transition-fast);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.header-actions button:disabled {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
opacity: 0.5;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
cursor: not-allowed;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.panels {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: grid;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
grid-template-columns: 340px 1fr;
|
|
|
|
|
|
gap: var(--spacing-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
align-items: start;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 1100px) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
.panels {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-card {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.05);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
padding: var(--spacing-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-md);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-header {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.empty-state {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
padding: var(--spacing-md);
|
|
|
|
|
|
border: 1px dashed rgba(255, 255, 255, 0.15);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
text-align: center;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--color-text-secondary);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.list-section {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
max-height: calc(100vh - 220px);
|
|
|
|
|
|
overflow-y: auto;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.playlist-list {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.playlist-card {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.05);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: 0.3rem;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
cursor: pointer;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
transition: border-color var(--transition-fast), background var(--transition-fast);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.playlist-card:hover {
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
border-color: rgba(255, 255, 255, 0.2);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.playlist-card.selected {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
border-color: var(--color-primary);
|
|
|
|
|
|
background: rgba(102, 126, 234, 0.1);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-title {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
font-weight: 600;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-metrics,
|
|
|
|
|
|
.card-meta {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-artist {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
margin-top: 0.15rem;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.artist-label {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--color-primary);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
font-style: italic;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-actions {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
|
|
|
|
|
margin-top: 0.25rem;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-card {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
border-radius: var(--radius-lg);
|
|
|
|
|
|
padding: var(--spacing-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
min-height: 400px;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-md);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-header {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: grid;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
grid-template-columns: 100px 1fr auto;
|
|
|
|
|
|
gap: var(--spacing-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
align-items: center;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-25 20:50:51 +01:00
|
|
|
|
.detail-header-main {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 0.25rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.detail-header-main h3 {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
margin: 0;
|
|
|
|
|
|
display: flex;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
align-items: center;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-lg);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-cover {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
width: 100px;
|
|
|
|
|
|
height: 100px;
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
overflow: hidden;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.06);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-cover img {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-id {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
margin: 0;
|
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
font-size: var(--text-xs);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-header-actions button {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
padding: 0.4rem 0.8rem;
|
|
|
|
|
|
color: var(--color-text);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
cursor: pointer;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
transition: background var(--transition-fast);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-header-actions button:hover {
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.14);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@media (max-width: 900px) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
.detail-header {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
grid-template-columns: 80px 1fr;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
}
|
2025-12-17 15:25:02 +01:00
|
|
|
|
|
2026-01-04 18:54:31 +01:00
|
|
|
|
.detail-header-actions {
|
|
|
|
|
|
grid-column: span 2;
|
|
|
|
|
|
justify-self: flex-start;
|
|
|
|
|
|
}
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cover-placeholder {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
font-size: 1.75rem;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cover-placeholder.small {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
font-size: 1rem;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cover-preview-row {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
align-items: center;
|
|
|
|
|
|
flex-wrap: wrap;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cover-preview {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
width: 80px;
|
|
|
|
|
|
height: 80px;
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
overflow: hidden;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.05);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cover-preview img {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cover-preview.small {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
width: 60px;
|
|
|
|
|
|
height: 60px;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-compact {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
padding: 0.2rem 0.5rem;
|
|
|
|
|
|
font-size: var(--text-xs);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.detail-stats {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-md);
|
|
|
|
|
|
font-size: var(--text-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.cover-stat code {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
padding: 0.15rem 0.4rem;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
border-radius: var(--radius-sm);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
margin-right: 0.4rem;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-xs);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 14:26:07 +01:00
|
|
|
|
.detail-messages .error,
|
|
|
|
|
|
.detail-messages .success {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
margin: 0;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-subcard {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
padding: var(--spacing-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.section-subcard h4 {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
margin: 0;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
font-weight: 600;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
form {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.field,
|
|
|
|
|
|
.checkbox {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: 0.3rem;
|
|
|
|
|
|
font-size: var(--text-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.checkbox {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
flex-direction: row;
|
|
|
|
|
|
align-items: center;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 14:26:07 +01:00
|
|
|
|
.field input,
|
|
|
|
|
|
.field select,
|
|
|
|
|
|
.field textarea {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(0, 0, 0, 0.3);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
padding: 0.45rem var(--spacing-sm);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
font-size: var(--text-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.field textarea {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
resize: vertical;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.field-grid {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: grid;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
|
|
|
|
gap: var(--spacing-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.field-group {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.form-messages {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
min-height: 1rem;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.primary {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: var(--color-primary);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
color: #fff;
|
|
|
|
|
|
border: none;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
padding: 0.5rem var(--spacing-md);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
font-weight: 600;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
transition: opacity var(--transition-fast);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.primary:disabled {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: not-allowed;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-secondary {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
padding: 0.4rem 0.8rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: background var(--transition-fast);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-secondary:hover {
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.14);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-danger {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(192, 57, 43, 0.8);
|
|
|
|
|
|
border: 1px solid rgba(211, 84, 0, 0.6);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
color: #fff;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
padding: 0.4rem 0.8rem;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: background var(--transition-fast);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.btn-danger:hover {
|
|
|
|
|
|
background: rgba(192, 57, 43, 1);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
button:disabled {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: not-allowed;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pill {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
padding: 0.1rem 0.55rem;
|
|
|
|
|
|
font-size: var(--text-xs);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.small {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.muted {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--color-text-secondary);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.tracks .section-header {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
margin-bottom: var(--spacing-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-06 17:48:30 +02:00
|
|
|
|
.pagination-controls {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
gap: var(--spacing-md);
|
|
|
|
|
|
padding: var(--spacing-md);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
margin-bottom: var(--spacing-md);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pagination-controls button {
|
|
|
|
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
|
|
|
|
background: var(--color-bg-secondary);
|
|
|
|
|
|
border: 1px solid var(--color-border);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
color: var(--color-text);
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pagination-controls button:disabled {
|
|
|
|
|
|
opacity: 0.5;
|
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pagination-controls button:hover:not(:disabled) {
|
|
|
|
|
|
background: var(--color-bg-tertiary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.page-info {
|
|
|
|
|
|
font-size: var(--text-sm);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.track-grid {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.track-card {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: grid;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
grid-template-columns: 80px 1fr auto;
|
|
|
|
|
|
gap: var(--spacing-md);
|
|
|
|
|
|
padding: var(--spacing-sm) var(--spacing-md);
|
|
|
|
|
|
border-radius: var(--radius-md);
|
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.04);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
align-items: center;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
transition: background var(--transition-fast);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-card:hover {
|
|
|
|
|
|
background: rgba(255, 255, 255, 0.07);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
@media (max-width: 900px) {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
.track-card {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
grid-template-columns: 80px 1fr;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
}
|
2025-12-17 15:25:02 +01:00
|
|
|
|
|
2026-03-25 20:50:51 +01:00
|
|
|
|
.track-actions {
|
|
|
|
|
|
grid-column: span 2;
|
|
|
|
|
|
flex-direction: row;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
}
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-card.lazy {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
border-color: rgba(212, 160, 23, 0.5);
|
|
|
|
|
|
background: rgba(212, 160, 23, 0.05);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cover-wrapper {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
width: 80px;
|
|
|
|
|
|
height: 80px;
|
|
|
|
|
|
border-radius: var(--radius-md);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
overflow: hidden;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(255, 255, 255, 0.06);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
position: relative;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
flex-shrink: 0;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.cover-wrapper img {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
object-fit: cover;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-card .cover-placeholder {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: 1.5rem;
|
|
|
|
|
|
color: var(--color-text-secondary);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.cover-lazy-pill {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
position: absolute;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
right: 4px;
|
|
|
|
|
|
bottom: 4px;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
background: #d4a017;
|
|
|
|
|
|
color: #1a1a1a;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
padding: 0.1rem 0.4rem;
|
|
|
|
|
|
border-radius: var(--radius-full);
|
|
|
|
|
|
font-size: 0.65rem;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
font-weight: 600;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.track-info {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: 0.2rem;
|
|
|
|
|
|
min-width: 0;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.track-title {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-sm);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
font-weight: 600;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.track-artist,
|
|
|
|
|
|
.track-album {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
|
text-overflow: ellipsis;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.track-meta {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
margin-top: 0.15rem;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.pk-info {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: 0.2rem;
|
|
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
|
color: var(--color-text-secondary);
|
|
|
|
|
|
margin-top: 0.25rem;
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.pk-line {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: 0.3rem;
|
2026-01-04 18:54:31 +01:00
|
|
|
|
align-items: center;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
2025-12-17 14:26:07 +01:00
|
|
|
|
|
2025-12-17 15:25:02 +01:00
|
|
|
|
.pk-line .label {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: 0.7rem;
|
|
|
|
|
|
color: var(--color-text-secondary);
|
2026-01-04 18:54:31 +01:00
|
|
|
|
text-transform: uppercase;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
flex-shrink: 0;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.pk-line code {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
background: rgba(0, 0, 0, 0.3);
|
|
|
|
|
|
padding: 0.1rem 0.35rem;
|
|
|
|
|
|
border-radius: var(--radius-sm);
|
|
|
|
|
|
font-size: 0.7rem;
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.added-info {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
font-size: var(--text-xs);
|
|
|
|
|
|
color: var(--color-text-secondary);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.track-actions {
|
2026-01-04 18:54:31 +01:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-03-25 20:50:51 +01:00
|
|
|
|
gap: var(--spacing-sm);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.error {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--status-offline);
|
2025-12-17 15:25:02 +01:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.success {
|
2026-03-25 20:50:51 +01:00
|
|
|
|
color: var(--status-playing);
|
2025-12-17 14:26:07 +01:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|