Files
SoulStream/src/mobile/HalfSheet.svelte
T
KeelrLeek 6e39dc43f3 android: playlists, the import doors, the share sheet, You → Sources — and three bugs the walks found
The desktop's two playlist pages on the phone: the shelf (pinned first,
mosaic covers, badges, New playlist, Import) and the page (kind, Pinned
chip, description, the meta line's warnings, Play / Shuffle / more, a
filter, rows tagged unresolved / set audio / file missing). Sheets for a
card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm;
Add to playlist on every track and record sheet; a removal undoable for
seven seconds from the neutral toast (notice.svelte.js); stream rows play
through the resolver as path-less entries, a set slice starts and ends at
its cues; Add to library / Download all tracks with the desktop's bulk
rules; nav.replace for a review that became the thing it made.

The import doors that need no file — a link (YouTube · Spotify · Apple
Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the
review screen and the desktop's catalog verification pass. A share plugin
crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps
the text in a ShareActivity and the WebView hands it to the import when it
comes back to the front. You → Sources: the source-quality window and the
priority per mode.

Found by the walks and fixed: set_settings takes camelCase arguments, so
the phone's normalization and library-folder saves never landed;
Cover.svelte's non-eager wrapper has no layout box, so its intersection
observer never fired and row thumbnails never loaded (the Artists tab too);
a history push while a history.go was still in flight left the nav mirror
one entry ahead and the page blank after a confirmed delete — the mirror
now applies once per microtask and never pushes during a pending traversal.

The harness names unmocked commands, carries console warnings' frames (the
smoke bundle is built unminified), probes the history mirror on every step,
mocks app_status / play_local / waveform / lyrics_get / player_state and
applies settings patches, runs adb steps on the phone (the share intent),
and walks playlists, the imports, the share and the settings on both the
mock and the phone. ANDROID.md documents all of it.
2026-09-13 17:56:52 -05:00

410 lines
26 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
/* Bottom sheets — the phone's answer to the desktop's right-click menus
and toolbar popovers. Only rows that do something are here; what a
row cannot do yet is not a dead row, it is absent. Facts about a file
are key/value rows, not buttons. */
import Icon from "../components/Icon.svelte";
import Cover from "./Cover.svelte";
import * as api from "../lib/api.js";
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
import { streamSong, saveSong } from "./resolve.svelte.js";
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist } from "./playlists.svelte.js";
import { DOORS, start as startImport } from "./importer.svelte.js";
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
import { lib, albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
/* the last sheet is kept through its slide down */
let shown = $state(null);
$effect(() => { if (nav.half) shown = nav.half; });
const kind = $derived.by(() => {
const h = shown;
if (!h) return null;
if (h.kind !== "more") return h.kind;
const t = top();
if (t.n === "album") return "album-more";
if (t.n === "library") return "library-more";
if (t.n === "playlist") return "playlist-more";
return "home-more";
});
/* an album sheet that arrived without its tracks (the page's overflow, a
long press on a card) reads them itself */
let resolved = $state(null);
$effect(() => {
const want = kind === "album-more" ? albumById(top().c.id)
: kind === "album" && shown?.arg?.album && !shown.arg.tracks && shown.arg.album.id != null ? shown.arg.album
: null;
if (!want) { resolved = null; return; }
let live = true;
resolved = { album: want, tracks: [] };
tracksOf(want.id).then((t) => { if (live) resolved = { album: want, tracks: t || [] }; }, () => {});
return () => { live = false; };
});
const albumArg = $derived(
kind === "album" ? (shown?.arg?.tracks ? shown.arg : resolved)
: kind === "album-more" ? resolved
: null,
);
const trackArg = $derived(kind === "track" ? shown?.arg : null);
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
/* ── playlists: the shelf card's sheet, the page's overflow (the bar's
More resolves to it), a row's sheet, the pick-a-playlist sheet, and
the prompt / confirm sheets they open ── */
const plArg = $derived.by(() => {
if (kind === "playlist") {
const id = shown?.arg?.playlist?.id;
if (id == null) return null;
return { playlist: lib.playlists.find((p) => p.id === id) || shown.arg.playlist, items: null };
}
if (kind !== "playlist-more") return null;
const id = shown?.arg?.playlist?.id ?? top().c.id;
const p = lib.playlists.find((x) => x.id === id);
if (!p) return null;
return { playlist: p, items: pls.current?.id === p.id ? pls.current.items : (shown?.arg?.items || []) };
});
const oops = (e) => { player.error = String(e); };
async function playPl(p, shuffle = false) {
try {
const items = (await api.playlistItems(p.id)) || [];
if (!(shuffle ? shufflePlay(p, items) : playFrom(p, items))) player.error = "Nothing in “" + p.name + "” can play yet.";
} catch (e) { oops(e); }
}
function confirmDelete(p) {
openHalf("confirm", {
title: "Delete “" + p.name + "”?",
body: (p.track_count ? "Its " + p.track_count + " row" + (p.track_count === 1 ? "" : "s") + " go with it. " : "") + "The files stay in your library.",
ok: "Delete", danger: true,
onconfirm: async () => {
try {
if (top().n === "playlist" && top().c.id === p.id) back();
await deletePlaylist(p);
} catch (e) { oops(e); }
},
});
}
/* the prompts take the playlist as a VALUE: a {@const} of the sheet's
block is gone by the time the prompt submits (the sheet switched
kinds), and reading it then is a stale derived */
function renamePrompt(p) {
openHalf("prompt", { title: "Rename playlist", value: p.name, placeholder: "Name", ok: "Rename", onsubmit: (v) => renamePlaylist(p, v).catch(oops) });
}
function describePrompt(p) {
openHalf("prompt", { title: "Description", value: p.description || "", placeholder: "What this playlist is", ok: "Save", multiline: true, allowEmpty: true, onsubmit: (v) => describePlaylist(p, v).catch(oops) });
}
function newPlaylistThenAdd(entries) {
openHalf("prompt", {
title: "New playlist", placeholder: "Name", value: "New Playlist " + (lib.playlists.length + 1), ok: "Create",
onsubmit: async (name) => {
try {
const id = await createPlaylist(name);
await addEntries(lib.playlists.find((x) => x.id === id) || { id, name }, entries);
} catch (e) { oops(e); }
},
});
}
/* the import doors: a prompt for the link or the text, then the review
screen while the backend reads and resolves */
function importPrompt(door) {
const d = DOORS.find((x) => x[0] === door);
openHalf("prompt", {
title: d[2].replace(/…$/, ""),
ok: "Import",
multiline: door === "text",
placeholder: door === "text" ? "00:00 Bicep - Glue\n05:30 Overmono - So U Kno\nFour Tet - Baby\n…or pasted track links"
: door === "youtube" ? "https://www.youtube.com/watch?v=…"
: "https://open.spotify.com/playlist/…",
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
});
}
/* the prompt's field, seeded when the prompt opens */
let promptVal = $state("");
$effect(() => {
const h = nav.half;
if (h?.kind === "prompt") promptVal = h.arg?.value ?? "";
});
function submitPrompt() {
const a = shown?.arg;
if (!a) return;
const v = promptVal.trim();
if (!v && !a.allowEmpty) return;
closeHalf();
a.onsubmit?.(v);
}
function focusLater(node) {
const t = setTimeout(() => node.focus(), 280);
return { destroy: () => clearTimeout(t) };
}
/* a catalog track (no file on this phone): the resolver plays it, Save
fetches it — the desktop's row buttons, on the sheet */
const isCatalog = (t) => !!t && !t.path && !!t.catalog;
let saveNote = $state("");
async function saveTrack(track, album) {
saveNote = "…";
try {
const r = await saveSong(track.artist || album?.artist, track.title);
saveNote = r ? `Saving from ${r.username}` : "No sources";
} catch (e) { saveNote = "Failed"; player.error = String(e); }
setTimeout(() => { saveNote = ""; closeHalf(); }, 1400);
}
function playCatalog(track, album) {
closeHalf();
streamSong(track.artist || album?.artist, track.title, {
trackId: track.catalog?.trackId ?? null, albumId: track.catalog?.albumId ?? album?.catalogId ?? null,
album: album?.title || null, cover: track.catalog?.cover ?? album?.art_path ?? null, expectSecs: track.duration_secs ?? null,
}).catch((e) => { player.error = String(e); });
}
function act(fn) {
return () => { closeHalf(); if (fn) fn(); };
}
async function rescan() {
try { await api.rescanLibrary(); await loadLibrary(); } catch (e) { player.error = String(e); }
}
</script>
{#snippet row(icon, t, m, fn)}
<button class="row" onclick={fn}>
<span class="lead"><Icon name={icon} size={20} /></span>
<span class="b"><span class="t">{t}</span>{#if m}<span class="m">{m}</span>{/if}</span>
</button>
{/snippet}
<div class="halfsheet" class:up={!!nav.half}>
<div class="grab"></div>
<div class="hs-body">
{#if kind === "track" && trackArg}
{@const album = trackArg.album}
{@const track = trackArg.track}
<div class="sheet-head">
<span class="art">{#if isCatalog(track) && (track.catalog?.cover || album?.art_path)}<img src={track.catalog?.cover || album?.art_path} alt="" />{:else}<Cover path={album?.art_path} eager size={22} />{/if}</span>
<span class="b"><span class="t">{track.title}</span><span class="m">{track.artist || album?.artist} · {album?.title}</span></span>
</div>
<div class="sheet-rows">
{#if isCatalog(track)}
{@render row("play", "Play", "streams from the best source", () => playCatalog(track, album))}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack(track, album); })}
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
<div class="sheet-hair"></div>
{#if album?.catalogId != null}
{@render row("disc", "Go to record", album.title, act(() => push("catalogAlbum", { id: album.catalogId, artist: album.artist, title: album.title })))}
{/if}
{#if album?.artist || track.artist}
{@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))}
{/if}
{:else}
{@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([{ t: track, album }])))}
{@render row("playlist", "Add to playlist", "", () => openHalf("pickPlaylist", { entries: [{ t: track, album }] }))}
<div class="sheet-hair"></div>
{#if album?.id != null && !onThisAlbum(album.id)}
{@render row("disc", "Go to album", album.title, act(() => push("album", { id: album.id })))}
{/if}
{#if album?.artist || track.artist}
{@render row("users", "Go to artist", track.artist || album.artist, act(() => push("artist", { name: track.artist || album.artist })))}
{/if}
{/if}
</div>
{#if !isCatalog(track)}
<div class="sheet-info">
<div class="kv"><span class="k">Format</span><span class="v">{fmtOf(track) || "—"}</span></div>
{#if track.size}<div class="kv"><span class="k">Size</span><span class="v">{fmtBytes(track.size)}</span></div>{/if}
<div class="kv"><span class="k">File</span><span class="v mono">{basename(track.path)}</span></div>
<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(track.path)}</span></div>
</div>
{/if}
{:else if (kind === "album" || kind === "album-more") && albumArg}
{@const album = albumArg.album}
{@const tracks = albumArg.tracks || []}
<div class="sheet-head">
<span class="art"><Cover path={album.art_path} eager size={22} /></span>
<span class="b"><span class="t">{album.title}</span><span class="m">{album.artist} · {album.year ?? "—"} · {tracks.length || album.track_count} tracks</span></span>
</div>
<div class="sheet-rows">
{@render row("play", "Play next", "", act(() => playNext(tracks.map((t) => ({ t, album })))))}
{@render row("list-plus", "Add to queue", tracks.length + " tracks", act(() => enqueue(tracks.map((t) => ({ t, album })))))}
{#if tracks.length}
{@render row("playlist", "Add to playlist", tracks.length + " tracks", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t, album })) }))}
{/if}
<div class="sheet-hair"></div>
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
</div>
<div class="sheet-info">
<div class="kv"><span class="k">Format</span><span class="v">{albumFmt(tracks) || "—"}</span></div>
<div class="kv"><span class="k">Length</span><span class="v">{longDur(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0))}</span></div>
{#if tracks[0]?.path}<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(tracks[0].path)}</span></div>{/if}
</div>
{:else if (kind === "playlist" || kind === "playlist-more") && plArg}
{@const p = plArg.playlist}
{@const rows = plArg.items || []}
{@const unres = rows.filter((i) => i.kind === "unresolved")}
<div class="sheet-head">
<span class="art">{#if coverFor(p)}<img src={coverFor(p)} alt="" />{:else}<Icon name="playlist" size={22} />{/if}</span>
<span class="b"><span class="t">{p.name}</span><span class="m">{p.kind === "setlist" ? "Setlist" : "Playlist"} · {p.track_count} track{p.track_count === 1 ? "" : "s"}{p.duration_secs ? " · " + longDur(p.duration_secs) : ""}{p.pinned ? " · pinned" : ""}</span></span>
</div>
<div class="sheet-rows">
{#if kind === "playlist"}
{@render row("play", "Play", "", act(() => playPl(p)))}
{@render row("shuffle", "Shuffle", "", act(() => playPl(p, true)))}
<div class="sheet-hair"></div>
{/if}
{@render row("pin", p.pinned ? "Unpin" : "Pin", p.pinned ? "" : "keeps it at the top of the shelf", act(() => togglePin(p).catch(oops)))}
{@render row("pencil", "Rename", "", () => renamePrompt(p))}
{#if kind === "playlist-more"}
{@render row("file-text", p.description ? "Edit description" : "Add a description", p.description || "", () => describePrompt(p))}
{/if}
{@render row("layers", "Duplicate", "a copy, “" + p.name + " copy”", act(() => duplicatePlaylist(p).then((id) => push("playlist", { id })).catch(oops)))}
{#if kind === "playlist-more" && unres.length}
{@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))}
{/if}
{#if kind === "playlist-more" && bulkable(rows) && !bulk.active}
<div class="sheet-hair"></div>
{@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))}
{@render row("download", "Download all tracks", bulkable(rows) + " not in the library yet, by your download rules", act(() => { void runBulk(p, rows, "download"); }))}
{/if}
<div class="sheet-hair"></div>
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}
</div>
{:else if kind === "pltrack" && shown?.arg?.item}
{@const p = shown.arg.playlist}
{@const rows = shown.arg.items || []}
{@const item = shown.arg.item}
<div class="sheet-head">
<span class="art">{#if item.path && item.art_path}<Cover path={item.art_path} eager size={22} />{:else if item.art_url}<img src={item.art_url} alt="" />{:else}<Icon name="music" size={22} />{/if}</span>
<span class="b"><span class="t">{item.title}</span><span class="m">{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""}</span></span>
</div>
<div class="sheet-rows">
{#if queueable(item)}
{@render row("play", "Play from here", "the rest of the playlist follows", act(() => playFrom(p, rows, item)))}
{@render row("skip-forward", "Play next", "", act(() => playNext([entryOf(item, p)])))}
{@render row("list-plus", "Add to queue", after ? after + " after this" : "", act(() => enqueue([entryOf(item, p)])))}
{#if !item.path}
{@render row(saveNote.startsWith("Saving") ? "check" : "download", saveNote || "Save to library", saveNote ? "" : "the best copy by your download rules", () => { if (!saveNote) saveTrack({ title: item.title, artist: item.artist }, null); })}
{/if}
{:else if item.kind === "unresolved"}
<div class="sec-note" style="padding: 2px var(--pad) 10px">This row never matched a track{item.source_line ? " — it came in as “" + item.source_line + "”" : ""}. Repair it on the desktop, or remove it.</div>
{:else if item.missing}
<div class="sec-note" style="padding: 2px var(--pad) 10px">The file is no longer where the library last saw it.</div>
{/if}
{#if item.artist}
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
{/if}
<div class="sheet-hair"></div>
{@render row("x", "Remove from playlist", "undo for a few seconds after", act(() => removeItems(p, rows, [item]).catch(oops)))}
</div>
{:else if kind === "pickPlaylist" && shown?.arg?.entries}
{@const entries = shown.arg.entries}
<div class="sheet-title">Add to playlist <span class="t-sub">· {entries.length === 1 ? entries[0].t.title : entries.length + " tracks"}</span></div>
<div class="sheet-rows">
{@render row("plus", "New playlist", "", () => newPlaylistThenAdd(entries))}
<div class="sheet-hair"></div>
{#each lib.playlists as p (p.id)}
<button class="row" onclick={act(() => addEntries(p, entries).catch(oops))}>
<span class="art">{#if coverFor(p)}<img src={coverFor(p)} alt="" />{:else}<Icon name="playlist" size={18} />{/if}</span>
<span class="b"><span class="t">{p.name}</span><span class="m">{p.track_count} track{p.track_count === 1 ? "" : "s"}{p.pinned ? " · pinned" : ""}</span></span>
</button>
{/each}
{#if !lib.playlists.length}<div class="sec-note" style="padding-bottom: 12px">No playlists yet — make one above.</div>{/if}
</div>
{:else if kind === "prompt" && shown?.arg}
{@const a = shown.arg}
<div class="sheet-title">{a.title}</div>
<div class="prompt">
{#if a.multiline}
<textarea class="field area" bind:value={promptVal} placeholder={a.placeholder || ""} rows="3" use:focusLater></textarea>
{:else}
<input class="field" bind:value={promptVal} placeholder={a.placeholder || ""} autocomplete="off" spellcheck="false" enterkeyhint="done" onkeydown={(e) => { if (e.key === "Enter") submitPrompt(); }} use:focusLater />
{/if}
<div class="prompt-acts">
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
<button class="btn btn-fill" disabled={!a.allowEmpty && !promptVal.trim()} onclick={submitPrompt}>{a.ok || "OK"}</button>
</div>
</div>
{:else if kind === "confirm" && shown?.arg}
{@const a = shown.arg}
<div class="sheet-title">{a.title}</div>
{#if a.body}<div class="sec-note" style="padding-bottom: 12px">{a.body}</div>{/if}
<div class="prompt-acts" style="padding: 0 var(--pad) 16px">
<button class="btn btn-ghost" onclick={closeHalf}>Cancel</button>
<button class="btn btn-fill" class:danger={a.danger} onclick={() => { const f = a.onconfirm; closeHalf(); f?.(); }}>{a.ok || "OK"}</button>
</div>
{:else if kind === "import"}
<div class="sheet-title">Import a playlist</div>
<div class="sheet-rows">
{#each DOORS as [door, icon, t, m] (door)}
{@render row(icon, t, m, () => importPrompt(door))}
{/each}
</div>
<div class="sec-note" style="padding-bottom: 14px">A playlist file or a folder of audio files imports on the desktop, where the files are.</div>
{:else if kind === "librarysort"}
<div class="sheet-title">Sort &amp; view</div>
<div class="sheet-sec">Order</div>
<div class="chips" style="padding-bottom:12px">
{#each SORTS as [k, l] (k)}
<button class="chip" class:on={(top().c.sort || "added") === k} class:quiet={(top().c.sort || "added") !== k} onclick={() => patch({ sort: k, tab: "albums" })}>{l}</button>
{/each}
</div>
<div class="sheet-sec">View</div>
<div class="chips" style="padding-bottom:12px">
{#each [["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
<button class="chip" class:on={(top().c.view || "grid") === k} class:quiet={(top().c.view || "grid") !== k} onclick={() => patch({ view: k, tab: "albums" })}>{l}</button>
{/each}
</div>
<div style="padding: 4px var(--pad) 16px"><button class="btn btn-fill wide" onclick={closeHalf}>Done</button></div>
{:else if kind === "library-more"}
<div class="sheet-title">Library</div>
<div class="sheet-rows">
{@render row("layers", "Sort and view", "", () => openHalf("librarysort"))}
{@render row("playlist", "Playlists", "", act(() => patch({ tab: "playlists" })))}
<div class="sheet-hair"></div>
{@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))}
</div>
{:else if kind === "home-more"}
<div class="sheet-title">SoulStream</div>
<div class="sheet-rows">
{#if player.track}
{@render row("list-plus", "Queue", after + " after this", act(() => { nav.face = "queue"; openSheet(null); }))}
{/if}
{@render row("clock", "Sleep timer", sleepLabel(), () => openHalf("sleep"))}
{@render row("settings", "Settings", "", act(() => go("you")))}
</div>
{:else if kind === "community"}
<div class="sheet-title">Community</div>
<div class="sheet-rows">
{@render row("refresh", "Refresh the rooms", slsk.rooms.length + " on the server", act(refreshRooms))}
{@render row("x", "Sign out", slsk.username, act(disconnect))}
</div>
<div class="sec-note" style="padding-bottom:16px">Spaces, holdings and the sealed inbox wait for the private layer on the phone.</div>
{:else if kind === "roomusers" && shown?.arg?.name}
{@const room = slsk.joined[shown.arg.name]}
<div class="sheet-title">#{shown.arg.name} <span class="t-sub">· {room?.users.length ?? 0} here</span></div>
<div class="sheet-rows">
{#each (room?.users ?? []).slice(0, 120) as u (u.username)}
{@const buddy = slsk.buddies.includes(u.username)}
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); (buddy ? removeBuddy : addBuddy)(u.username).catch(() => {}); }}>
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
<span class="b"><span class="t">{u.username}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{u.file_count ? u.file_count.toLocaleString() + " files" : ""}</span></span>
</button>
{/each}
</div>
<div style="padding: 6px var(--pad) 16px"><button class="btn btn-ghost wide" onclick={act(() => { leaveRoom(shown.arg.name).catch(() => {}); back(); })}>Leave #{shown.arg.name}</button></div>
{:else if kind === "sleep"}
<div class="sheet-title">Sleep timer</div>
<div class="sheet-sec">Pause after</div>
<div class="chips" style="padding-bottom:16px">
{#each [[0, "Off"], [15, "15 min"], [30, "30 min"], [45, "45 min"], [60, "1 hour"], [90, "1½ hours"]] as [m, l] (m)}
<button class="chip" class:on={m === 0 ? !player.sleepAt : Math.abs((player.sleepAt - Date.now()) / 60000 - m) < 1} class:quiet={!(m === 0 ? !player.sleepAt : Math.abs((player.sleepAt - Date.now()) / 60000 - m) < 1)} onclick={act(() => setSleep(m))}>{l}</button>
{/each}
</div>
{#if player.sleepAt}<div class="sec-note" style="padding-bottom:14px">Playback pauses in {sleepLabel().replace(" left", "")}.</div>{/if}
{/if}
</div>
</div>