Files
pmomusic/pmoapp/webapp/src/components/PlayListManager.vue

2117 lines
70 KiB
Vue
Raw Normal View History

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>
<!-- Pagination controls -->
<div v-if="totalPages > 1" class="pagination-controls">
<button @click="prevPage" :disabled="currentPage === 0">
&larr; Previous
</button>
<span class="page-info">
Page {{ currentPage + 1 }} of {{ totalPages }}
</span>
<button @click="nextPage" :disabled="currentPage >= totalPages - 1">
Next &rarr;
</button>
</div>
2026-01-04 18:54:31 +01:00
<div
class="track-grid"
v-if="paginatedTracks.length > 0"
2026-01-04 18:54:31 +01:00
>
<article
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
});
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 [];
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(),
);
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(() =>
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 {
padding: var(--spacing-md);
2026-01-04 18:54:31 +01:00
width: 100%;
max-width: 1400px;
margin: 0 auto;
box-sizing: border-box;
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 {
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;
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;
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;
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;
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 {
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 {
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
2026-01-04 18:54:31 +01:00
border: none;
background: var(--color-primary);
color: #fff;
2026-01-04 18:54:31 +01:00
font-weight: 600;
cursor: pointer;
font-size: var(--text-sm);
transition: opacity var(--transition-fast);
2025-12-17 15:25:02 +01:00
}
.header-actions button:disabled {
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;
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 {
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;
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;
gap: var(--spacing-sm);
2025-12-17 14:26:07 +01:00
}
.empty-state {
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;
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;
gap: var(--spacing-sm);
2025-12-17 14:26:07 +01:00
}
.playlist-card {
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;
gap: 0.3rem;
2026-01-04 18:54:31 +01:00
cursor: pointer;
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 {
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;
gap: var(--spacing-sm);
2026-01-04 18:54:31 +01:00
font-weight: 600;
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;
gap: var(--spacing-sm);
font-size: var(--text-xs);
color: var(--color-text-secondary);
2026-01-04 18:54:31 +01:00
}
.card-artist {
margin-top: 0.15rem;
2026-01-04 18:54:31 +01:00
}
.artist-label {
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;
gap: var(--spacing-sm);
margin-top: 0.25rem;
2025-12-17 14:26:07 +01:00
}
.detail-card {
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;
gap: var(--spacing-md);
2025-12-17 14:26:07 +01:00
}
.detail-header {
2026-01-04 18:54:31 +01:00
display: grid;
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
}
.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;
gap: var(--spacing-sm);
2026-01-04 18:54:31 +01:00
align-items: center;
font-size: var(--text-lg);
2025-12-17 15:25:02 +01:00
}
.detail-cover {
width: 100px;
height: 100px;
border-radius: var(--radius-md);
2026-01-04 18:54:31 +01:00
overflow: hidden;
background: rgba(255, 255, 255, 0.06);
2026-01-04 18:54:31 +01:00
display: flex;
align-items: center;
justify-content: center;
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 {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-xs);
2025-12-17 15:25:02 +01:00
}
.detail-header-actions button {
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;
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 {
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;
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;
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 {
width: 80px;
height: 80px;
border-radius: var(--radius-md);
2026-01-04 18:54:31 +01:00
overflow: hidden;
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 {
width: 60px;
height: 60px;
2025-12-17 15:25:02 +01:00
}
.btn-compact {
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;
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 {
background: rgba(255, 255, 255, 0.08);
2026-01-04 18:54:31 +01:00
padding: 0.15rem 0.4rem;
border-radius: var(--radius-sm);
2026-01-04 18:54:31 +01:00
margin-right: 0.4rem;
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 {
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;
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;
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;
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;
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 {
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;
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;
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 {
background: var(--color-primary);
2026-01-04 18:54:31 +01:00
color: #fff;
border: none;
padding: 0.5rem var(--spacing-md);
border-radius: var(--radius-md);
2026-01-04 18:54:31 +01:00
cursor: pointer;
font-weight: 600;
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 {
font-size: var(--text-sm);
2025-12-17 14:26:07 +01:00
}
.btn-secondary {
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 {
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;
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 {
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 {
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 {
color: var(--color-text-secondary);
2025-12-17 14:26:07 +01:00
}
2025-12-17 15:25:02 +01:00
.tracks .section-header {
margin-bottom: var(--spacing-sm);
2025-12-17 14:26:07 +01: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;
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;
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;
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 {
grid-template-columns: 80px 1fr;
2026-01-04 18:54:31 +01:00
}
2025-12-17 15:25:02 +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 {
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 {
width: 80px;
height: 80px;
border-radius: var(--radius-md);
2026-01-04 18:54:31 +01:00
overflow: hidden;
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;
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 {
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;
right: 4px;
bottom: 4px;
2026-01-04 18:54:31 +01:00
background: #d4a017;
color: #1a1a1a;
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;
gap: 0.2rem;
min-width: 0;
2025-12-17 14:26:07 +01:00
}
2025-12-17 15:25:02 +01:00
.track-title {
font-size: var(--text-sm);
2026-01-04 18:54:31 +01:00
font-weight: 600;
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 {
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;
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;
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;
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 {
font-size: 0.7rem;
color: var(--color-text-secondary);
2026-01-04 18:54:31 +01:00
text-transform: uppercase;
flex-shrink: 0;
2025-12-17 15:25:02 +01:00
}
.pk-line code {
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 {
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;
gap: var(--spacing-sm);
2025-12-17 15:25:02 +01:00
}
.error {
color: var(--status-offline);
2025-12-17 15:25:02 +01:00
}
.success {
color: var(--status-playing);
2025-12-17 14:26:07 +01:00
}
</style>