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.
410 lines
26 KiB
Svelte
410 lines
26 KiB
Svelte
<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 & 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>
|