playlists + player: every service's list through one door with its names matched for real, and shuffle that readies exactly what it promises

This commit is contained in:
KeelrLeek committed 2026-08-24 18:10:34 -05:00
1 parent 30eb6fe025
commit 50414dae5c
16 files changed
+4157 -154

No files matched your search

+65 -15
View File
@@ -19,6 +19,7 @@
let importMode = $state("file"); // "file" | "paste"
let importDraft = $state(null);
let menuFor = $state(null); // playlist id whose ⋯ menu is open
let exportFor = $state(null); // playlist id whose Export flyout is open
let confirmDelete = $state(null); // playlist id armed for deletion
let creating = $state(false);
let newMenuOpen = $state(false);
@@ -130,6 +131,13 @@
importOpen = true;
}
function importFromYoutubePlaylist() {
newMenuOpen = false;
importDraft = null;
importMode = "ytplaylist";
importOpen = true;
}
function open(pl) {
goToView("playlist", { playlistId: pl.id, label: pl.name });
}
@@ -211,6 +219,7 @@
async function exportAs(pl, format) {
menuFor = null;
exportFor = null;
try {
await api.playlistExport(pl.id, format);
} catch (e) {
@@ -307,6 +316,11 @@
YouTube DJ set…
<span class="hint">tracks from chapters, description or comments</span>
</button>
<button class="mi" onclick={importFromYoutubePlaylist}>
<Icon name="playlist" size={15} />
Playlist from a link…
<span class="hint">YouTube · Spotify · Apple Music · TIDAL · Deezer</span>
</button>
</div>
{/if}
</div>
@@ -420,21 +434,31 @@
<Icon name="layers" size={15} /> Duplicate
</button>
<div class="rule"></div>
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>
<Icon name="export" size={15} /> Export M3U8
</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>
<Icon name="export" size={15} /> Export XSPF
</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>
<Icon name="export" size={15} /> Export PLS
</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>
<Icon name="export" size={15} /> Export CSV
</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>
<Icon name="export" size={15} /> Export backup (JSON)
</button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="sub-wrap"
onmouseenter={() => (exportFor = pl.id)}
onmouseleave={() => (exportFor = null)}
>
<button
class="mi"
onclick={() => (exportFor = exportFor === pl.id ? null : pl.id)}
aria-haspopup="menu"
aria-expanded={exportFor === pl.id}
>
<Icon name="export" size={15} /> Export
<span class="sub-arrow"><Icon name="chevron-right" size={13} /></span>
</button>
{#if exportFor === pl.id}
<div class="menu sub" role="menu">
<button class="mi" onclick={() => exportAs(pl, "m3u8")}>M3U8</button>
<button class="mi" onclick={() => exportAs(pl, "xspf")}>XSPF</button>
<button class="mi" onclick={() => exportAs(pl, "pls")}>PLS</button>
<button class="mi" onclick={() => exportAs(pl, "csv")}>CSV</button>
<button class="mi" onclick={() => exportAs(pl, "json")}>Backup (JSON)</button>
</div>
{/if}
</div>
<div class="rule"></div>
<button class="mi danger" onclick={() => remove(pl)}>
<Icon name="trash" size={15} />
@@ -641,6 +665,32 @@
color: var(--text-muted);
padding-left: 14px;
}
.sub-wrap {
position: relative;
}
.sub-arrow {
margin-left: auto;
display: inline-flex;
color: var(--text-tertiary);
}
/* Same glass card, berthed beside its row; the ::before bridge covers
the gap so the pointer can cross without closing it. Card menus sit
at the grid's right edge as often as not, so the flyout opens LEFT. */
.menu.sub {
position: absolute;
right: calc(100% + 4px);
left: auto;
top: -6px;
min-width: 130px;
}
.menu.sub::after {
content: "";
position: absolute;
right: -8px;
top: 0;
bottom: 0;
width: 8px;
}
.menu .rule {
height: 1px;
background: var(--border);