Popular is asked on its own as well as with the page, so the section can paint before the full page is built and an artist whose page came back with none still gets one. A catalog record's rows that the album's own artist anchor found no count for — a feature's guest, a compilation's stranger — get the deferred Last.fm pass by their own artist id. The artist page's ⋯ is its own sheet now: choose a hero photo from this phone, or put the catalog's own back; set_artist_hero is pick_artist_hero's install step split out and shared, so the desktop's dialog keeps its door. You gains the desktop's Refresh artist portraits, and a data: URL is the image itself in the art gate — a chosen hero comes back from Rust as one, and the thumbnail fetch used to throw it away.
746 lines
50 KiB
Svelte
746 lines
50 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 Identicon from "../components/Identicon.svelte";
|
||
import { speedLabel, groupEntries } from "./net.svelte.js";
|
||
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, playEntries, playQueueAt, removeFromQueue, moveToNext, moveInQueue, setSleep, sleepLabel } from "./player.svelte.js";
|
||
import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js";
|
||
import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.svelte.js";
|
||
import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear } from "./playlists.svelte.js";
|
||
import { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.svelte.js";
|
||
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
|
||
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
|
||
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics, setAutoSync, openTapSync, tapProgress, reduceVocals, backToOriginal } from "./lyrics.svelte.js";
|
||
import { art, wantArt } from "./art.svelte.js";
|
||
import { metaOf, display, realName, setMeta, clearMeta } from "./contacts.svelte.js";
|
||
import { downloadsAllowed } from "./network.svelte.js";
|
||
import { note } from "./notice.svelte.js";
|
||
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
|
||
|
||
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"], ["format", "By format"]];
|
||
|
||
/* 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";
|
||
if (t.n === "catalogAlbum") return "catalogAlbum-more";
|
||
if (t.n === "artist") return "artist-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);
|
||
/* the catalog record page's sheet reads the page the store holds */
|
||
const catPage = $derived.by(() => {
|
||
if (kind === "catalogAlbum-more") return catalog.albums[top().c.id]?.page ?? null;
|
||
if (kind === "catalogAlbum" && shown?.arg?.album) {
|
||
const a = shown.arg.album;
|
||
return { album: { ...a, artist: a.artist || a.artistName || "" }, tracks: catalog.albums[a.id]?.page?.tracks ?? [] };
|
||
}
|
||
return null;
|
||
});
|
||
/* the lyrics sheet: a tap-sync set left mid-tap for the playing track,
|
||
and whether a vocal-reduced copy is what is playing */
|
||
const tapLeft = $derived(kind === "lyrics" ? tapProgress() : null);
|
||
const canReduce = $derived(!!player.track?.path && !player.track?.stream);
|
||
const onReduced = $derived(!!lyr.reduced && player.track?.path === lyr.reduced.to);
|
||
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 folder door picks FIRST: the review only opens for a folder that
|
||
was chosen, and a tree with no path on this phone cannot be walked */
|
||
async function folderDoor() {
|
||
closeHalf();
|
||
try {
|
||
const path = await pickImportFolder();
|
||
if (!path) return;
|
||
push("import", {});
|
||
void startFolder(path);
|
||
} catch (e) { oops(e); }
|
||
}
|
||
/* find a local match — the desktop's repair panel, on a sheet: the
|
||
library searched by the row's name, a pick relinks the row (an
|
||
unknown placeholder adopts the track's identity; the original line
|
||
survives in source_line) */
|
||
let matchQ = $state("");
|
||
let matchRows = $state([]);
|
||
let matchBusy = $state(false);
|
||
let matchGen = 0;
|
||
$effect(() => {
|
||
const h = nav.half;
|
||
if (h?.kind !== "plmatch" || !h.arg?.item) return;
|
||
matchQ = [h.arg.item.artist, h.arg.item.title].filter(Boolean).join(" ");
|
||
matchRows = [];
|
||
queueMicrotask(() => void runMatch());
|
||
});
|
||
async function runMatch() {
|
||
const g = ++matchGen;
|
||
const q = matchQ.trim();
|
||
if (!q) { matchRows = []; matchBusy = false; return; }
|
||
matchBusy = true;
|
||
try {
|
||
const rows = await api.libraryTrackSearch(q);
|
||
if (g === matchGen) matchRows = rows || [];
|
||
} catch {
|
||
if (g === matchGen) matchRows = [];
|
||
} finally {
|
||
if (g === matchGen) matchBusy = false;
|
||
}
|
||
}
|
||
async function applyMatch(p, item, c) {
|
||
const unknown = item.kind === "unresolved" && (!item.artist || item.title.trim().toLowerCase() === "id");
|
||
closeHalf();
|
||
try {
|
||
await api.playlistRelinkItem(p.id, item.id, {
|
||
path: c.path, artPath: c.art_path, durationSecs: c.duration_secs,
|
||
title: unknown ? c.title : null, artist: unknown ? c.artist : null, album: unknown ? c.album : null,
|
||
});
|
||
await refreshPlaylists();
|
||
note(`Linked to “${c.title}”`);
|
||
} catch (e) { oops(e); }
|
||
}
|
||
/* the desktop's Save menu: a streamed track filed into the library —
|
||
at once when its cache is byte-complete, else through the download
|
||
pipeline — and the whole streamed record the queue holds */
|
||
const streamMates = (track, album) => player.queue.filter((e) => e.t?.stream && e.t.path && e.album?.title === album?.title && e.album?.artist === album?.artist);
|
||
async function saveStreams(entries) {
|
||
if (!downloadsAllowed()) return;
|
||
const srcs = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path, size: e.t.stream.size }));
|
||
if (!srcs.length) return;
|
||
try {
|
||
const [organized, queued] = await api.saveToLibrary(srcs);
|
||
note(organized && !queued ? (organized === 1 ? "Saved to the library" : organized + " tracks saved to the library")
|
||
: queued ? queued + " downloading into the library" + (organized ? " · " + organized + " saved" : "")
|
||
: "Nothing to save");
|
||
} catch (e) { oops(e); }
|
||
}
|
||
/* a peer's sheet (a raw result's folder, or the browse screen's user) */
|
||
function playGroup(g) {
|
||
const es = groupEntries(g, 0);
|
||
if (es.length) playEntries(es, 0, g.folderName).catch(oops);
|
||
}
|
||
async function saveGroup(g) {
|
||
if (!downloadsAllowed()) return;
|
||
try {
|
||
for (const f of g.files) await api.downloadFile(g.username, f.path, f.size);
|
||
note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
|
||
} catch (e) { oops(e); }
|
||
}
|
||
/* the contact editor's three fields go through the prompt sheet. The
|
||
username travels as a VALUE, the way the playlist prompts do: the
|
||
sheet switches kind under the prompt, so the contact block's
|
||
{@const} is a destroyed derived by the time Save lands. */
|
||
function metaPrompt(u, field, title, placeholder, value, multiline = false) {
|
||
openHalf("prompt", {
|
||
title, value, placeholder, ok: "Save", multiline, allowEmpty: true,
|
||
onsubmit: (v) => { void setMeta(u, { [field]: v }); openHalf("contact", { username: u }); },
|
||
});
|
||
}
|
||
/* the desktop's per-record cover refresh; the thumbnail is asked for again */
|
||
async function refetchCover(album) {
|
||
try {
|
||
await api.refreshAlbumArt(album.id);
|
||
if (album.art_path) { delete art[album.art_path]; wantArt(album.art_path); }
|
||
note("Cover re-fetched for “" + album.title + "”");
|
||
} catch (e) { oops(e); }
|
||
}
|
||
/* 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}
|
||
|
||
{#snippet swrow(icon, t, m, on, 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>
|
||
<span class="sw" class:on><span></span></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 trackArg.queueIndex != null}
|
||
{@render row("play", "Play now", "", act(() => playQueueAt(trackArg.queueIndex)))}
|
||
{#if trackArg.queueIndex > player.qi + 1}
|
||
{@render row("skip-forward", "Move to next", "right after this one", act(() => moveToNext(trackArg.queueIndex)))}
|
||
{@render row("chevron-up", "Move up", "", act(() => moveInQueue(trackArg.queueIndex, "up")))}
|
||
{/if}
|
||
{#if trackArg.queueIndex < player.queue.length - 1}
|
||
{@render row("chevron-down", "Move down", "", act(() => moveInQueue(trackArg.queueIndex, "down")))}
|
||
{/if}
|
||
{@render row("x", "Remove from queue", "", act(() => removeFromQueue(trackArg.queueIndex)))}
|
||
<div class="sheet-hair"></div>
|
||
{/if}
|
||
{#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}
|
||
{#if track.stream}
|
||
{@render row("download", "Save to library", "this track, from " + track.stream.username, act(() => saveStreams([{ t: track, album }])))}
|
||
{#if streamMates(track, album).length > 1}
|
||
{@render row("download", "Save this record", streamMates(track, album).length + " tracks streaming from the same record", act(() => saveStreams(streamMates(track, album))))}
|
||
{/if}
|
||
<div class="sheet-hair"></div>
|
||
{/if}
|
||
{@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 })))}
|
||
{#if album.id != null}
|
||
<div class="sheet-hair"></div>
|
||
{@render row("image", "Re-fetch the cover", "the canonical high-resolution cover, into the folder and the tags", act(() => refetchCover(album)))}
|
||
{/if}
|
||
</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)))}
|
||
<div class="sheet-hair"></div>
|
||
{@render row("image", "Choose cover photo…", "a picture from this phone", act(() => coverFromPhoto(p).catch(oops)))}
|
||
{#if p.cover_path}
|
||
{@render row("x", "Remove custom cover", "the mosaic comes back", act(() => coverClear(p).catch(oops)))}
|
||
{/if}
|
||
{#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.kind !== "local" || item.missing}
|
||
{@render row("link", "Find a local match", "a track in your library takes this row's place", () => openHalf("plmatch", { playlist: p, items: rows, item }))}
|
||
{/if}
|
||
{#if item.artist}
|
||
{@render row("users", "Go to artist", item.artist, act(() => push("artist", { name: item.artist })))}
|
||
{/if}
|
||
{#if rows.length > 1}
|
||
{@const at = rows.findIndex((r) => r.id === item.id)}
|
||
<div class="sheet-hair"></div>
|
||
{#if at > 0}{@render row("chevron-up", "Move up", "", act(() => moveItem(p, rows, item, "up").catch(oops)))}{/if}
|
||
{#if at < rows.length - 1}{@render row("chevron-down", "Move down", "", act(() => moveItem(p, rows, item, "down").catch(oops)))}{/if}
|
||
{#if at > 1}{@render row("skip-back", "Move to the top", "", act(() => moveItem(p, rows, item, "top").catch(oops)))}{/if}
|
||
{/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, () => (door === "folder" ? folderDoor() : importPrompt(door)))}
|
||
{/each}
|
||
</div>
|
||
<div class="sec-note" style="padding-bottom: 14px">A playlist file — M3U, XSPF, PLS or CSV — imports on the desktop; the phone has no file picker for one yet.</div>
|
||
{:else if kind === "plmatch" && shown?.arg?.item}
|
||
{@const p = shown.arg.playlist}
|
||
{@const item = shown.arg.item}
|
||
<div class="sheet-title">Find a local match <span class="t-sub">· {item.title}</span></div>
|
||
<div class="sfield plmatch">
|
||
<Icon name="search" size={18} />
|
||
<input value={matchQ} oninput={(e) => { matchQ = e.currentTarget.value; void runMatch(); }} type="search" placeholder="Search your library" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search your library" />
|
||
</div>
|
||
<div class="sheet-rows">
|
||
{#if matchBusy && !matchRows.length}
|
||
<div class="sec-note" style="padding-bottom: 12px">Searching…</div>
|
||
{:else if !matchRows.length}
|
||
<div class="sec-note" style="padding-bottom: 12px">No library tracks match. A named row still streams from the network when you tap it.</div>
|
||
{:else}
|
||
{#each matchRows.slice(0, 30) as c (c.path)}
|
||
<button class="row" onclick={() => applyMatch(p, item, c)}>
|
||
<span class="art"><Cover path={c.art_path} size={16} /></span>
|
||
<span class="b"><span class="t">{c.title}</span><span class="m">{c.artist} — {c.album}</span></span>
|
||
<span class="r"><span class="n">{mmss(c.duration_secs)}</span></span>
|
||
</button>
|
||
{/each}
|
||
{/if}
|
||
</div>
|
||
{:else if kind === "peer" && shown?.arg?.username}
|
||
{@const u = shown.arg.username}
|
||
{@const g = shown.arg.group || null}
|
||
{@const buddy = slsk.buddies.includes(u)}
|
||
{@const m = metaOf(u)}
|
||
<div class="sheet-head">
|
||
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||
<span class="b"><span class="t">{display(u)}{#if m.favorite}<span class="nmark fav"><Icon name="star" size={12} /></span>{/if}{#if m.trusted}<span class="nmark tr"><Icon name="shield" size={12} /></span>{/if}</span><span class="m">{[realName(u), m.group, g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"].filter(Boolean).join(" · ")}</span></span>
|
||
</div>
|
||
<div class="sheet-rows">
|
||
{#if g}
|
||
{@render row("play", "Play this folder", g.files.length + " file" + (g.files.length === 1 ? "" : "s") + ", streamed as a record", act(() => playGroup(g)))}
|
||
{@render row("download", "Save this folder to library", "every file, as it is", act(() => saveGroup(g)))}
|
||
<div class="sheet-hair"></div>
|
||
{/if}
|
||
{#if !(top().n === "peer" && top().c.username === u)}
|
||
{@render row("users", "About this user", "their card, and what they have that you don't", act(() => push("peer", { username: u })))}
|
||
{/if}
|
||
{#if !(top().n === "browse" && top().c.username === u)}
|
||
{@render row("folder-open", "Browse their files", "everything they share", act(() => push("browse", { username: u })))}
|
||
{/if}
|
||
{@render row("message", "Message", "", act(() => { openThread(u); push("thread", { username: u }); }))}
|
||
{@render row(buddy ? "x" : "heart", buddy ? "Remove buddy" : "Add buddy", buddy ? "" : "buddies stay on the Community screen", act(() => (buddy ? removeBuddy : addBuddy)(u).catch(oops)))}
|
||
<div class="sheet-hair"></div>
|
||
{@render row("pencil", "Name and notes", "a petname, a group, a note — this phone only", () => openHalf("contact", { username: u }))}
|
||
</div>
|
||
{:else if kind === "contact" && shown?.arg?.username}
|
||
{@const u = shown.arg.username}
|
||
{@const m = metaOf(u)}
|
||
<div class="sheet-head">
|
||
<span class="art idc"><Identicon seed={u} size={44} /></span>
|
||
<span class="b"><span class="t">{display(u)}</span><span class="m">{realName(u) ? realName(u) + " · " : ""}kept on this phone, published nowhere</span></span>
|
||
</div>
|
||
<div class="sheet-rows">
|
||
{@render row("pencil", "Petname", m.petname || "what you call them here", () => metaPrompt(u, "petname", "Petname", u, m.petname || ""))}
|
||
{@render row("tag", "Group", m.group || "a word to sort them by", () => metaPrompt(u, "group", "Group", "Traders", m.group || ""))}
|
||
{@render row("file-text", "Note", m.note || "a private note, for you only", () => metaPrompt(u, "note", "Note", "What you want to remember about them", m.note || "", true))}
|
||
<div class="sheet-hair"></div>
|
||
<button class="row" onclick={() => setMeta(u, { favorite: !m.favorite })}>
|
||
<span class="lead"><Icon name="star" size={20} /></span>
|
||
<span class="b"><span class="t">Favorite</span><span class="m">kept at the top of the buddy list</span></span>
|
||
<span class="sw" class:on={m.favorite}><span></span></span>
|
||
</button>
|
||
<button class="row" onclick={() => setMeta(u, { trusted: !m.trusted })}>
|
||
<span class="lead"><Icon name="shield" size={20} /></span>
|
||
<span class="b"><span class="t">Trusted</span><span class="m">someone you take at their word</span></span>
|
||
<span class="sw" class:on={m.trusted}><span></span></span>
|
||
</button>
|
||
{#if m.petname || m.group || m.note || m.trusted || m.favorite}
|
||
<div class="sheet-hair"></div>
|
||
{@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))}
|
||
{/if}
|
||
</div>
|
||
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage}
|
||
{@const al = catPage.album}
|
||
{@const tracks = catPage.tracks || []}
|
||
<div class="sheet-head">
|
||
<span class="art">{#if al.cover}<img src={al.cover} alt="" />{:else}<Icon name="disc" size={22} />{/if}</span>
|
||
<span class="b"><span class="t">{al.title}</span><span class="m">{al.artist} · {yearOf(al.release_date) ?? "—"} · {tracks.length || al.track_count || "?"} tracks</span></span>
|
||
</div>
|
||
<div class="sheet-rows">
|
||
{@render row("play", "Play", "streams the record from one folder", act(() => streamCatalogAlbum(al, 0).catch(oops)))}
|
||
{@render row("download", "Save to library", "the best folder by your download rules", act(() => saveCatalogAlbum(al).then((r) => note(r ? `Saving ${r.files} from ${r.username}` : "No sources found")).catch(oops)))}
|
||
{#if tracks.length}
|
||
{@render row("playlist", "Add to playlist", tracks.length + " tracks, as streams", () => openHalf("pickPlaylist", { entries: tracks.map((t) => ({ t: { id: "cat:" + t.id, title: t.title, artist: t.artist || al.artist, path: null, duration_secs: t.duration_secs ?? null, catalog: { trackId: t.id, albumId: al.id, cover: al.cover ?? null } }, album: { id: null, catalogId: al.id, title: al.title, artist: al.artist, art_path: al.cover ?? null } })) }))}
|
||
{/if}
|
||
<div class="sheet-hair"></div>
|
||
{#if !(top().n === "catalogAlbum" && top().c.id === al.id)}
|
||
{@render row("disc", "Go to record", al.title, act(() => push("catalogAlbum", { id: al.id, artist: al.artist, title: al.title })))}
|
||
{/if}
|
||
{@render row("users", "Go to artist", al.artist, act(() => push("artist", { name: al.artist })))}
|
||
</div>
|
||
{:else if kind === "peerfile" && shown?.arg?.group}
|
||
{@const g = shown.arg.group}
|
||
{@const i = shown.arg.index || 0}
|
||
{@const f = g.files[i]}
|
||
<div class="sheet-head">
|
||
<span class="art idc"><Identicon seed={g.username} size={44} /></span>
|
||
<span class="b"><span class="t">{f ? titleFromName(f.name) : "—"}</span><span class="m">{g.username} · {g.folderName}{f ? " · " + (f.bitrate ? f.bitrate + " kbps" : (f.extension || f.ext || "").toUpperCase()) + " · " + fmtBytes(f.size) : ""}</span></span>
|
||
</div>
|
||
<div class="sheet-rows">
|
||
{@render row("play", "Play from here", "the rest of the folder follows", act(() => { const es = groupEntries(g, i); if (es.length) playEntries(es, 0, g.folderName).catch(oops); }))}
|
||
{#if f}
|
||
{@render row("download", "Save this file to library", "as it is, from " + g.username, act(() => { if (!downloadsAllowed()) return; api.downloadFile(g.username, f.path, f.size).then(() => note(`Saving “${titleFromName(f.name)}” from ${g.username}`)).catch(oops); }))}
|
||
{/if}
|
||
<div class="sheet-hair"></div>
|
||
{@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))}
|
||
</div>
|
||
{:else if kind === "lyrics"}
|
||
<div class="sheet-title">Lyrics <span class="t-sub">· {lyr.status === "loading" ? "looking" : lyr.source || lyr.status}{lyr.offset ? ` · ${lyr.offset > 0 ? "+" : ""}${(lyr.offset / 1000).toFixed(1)} s` : ""}</span></div>
|
||
{#if lyr.candidates}
|
||
<div class="sheet-sec">Choose the right lyrics</div>
|
||
<div class="sheet-rows">
|
||
{#if lyr.searching}
|
||
<div class="sec-note" style="padding-bottom: 12px">Searching LRCLIB…</div>
|
||
{:else if !lyr.candidates.length}
|
||
<div class="sec-note" style="padding-bottom: 12px">Nothing found for this track's name.</div>
|
||
{:else}
|
||
{#each lyr.candidates as c (c.id)}
|
||
<button class="row" onclick={act(() => { void chooseCandidate(c.id); })}>
|
||
<span class="lead"><Icon name={c.synced ? "lyrics" : "file-text"} size={20} /></span>
|
||
<span class="b"><span class="t">{c.title}</span><span class="m">{c.artist}{c.album ? " — " + c.album : ""}{c.synced ? " · synced" : " · plain"}{c.instrumental ? " · instrumental" : ""}</span></span>
|
||
<span class="r"><span class="n">{c.durationSecs ? mmss(c.durationSecs) : ""}</span></span>
|
||
</button>
|
||
{/each}
|
||
{/if}
|
||
<div style="padding: 8px var(--pad) 14px"><button class="btn btn-ghost wide" onclick={closeCandidates}>Keep what is showing</button></div>
|
||
</div>
|
||
{:else}
|
||
<div class="sheet-rows">
|
||
{#if lyr.lines.length}
|
||
{@render row("skip-back", "Lyrics earlier", "half a second, kept for this track", () => nudge(-500))}
|
||
{@render row("skip-forward", "Lyrics later", "half a second", () => nudge(500))}
|
||
{@render swrow("history", "Auto-sync this track", lyr.autoOffset ? `correcting by ${lyr.autoOffset > 0 ? "+" : ""}${(lyr.autoOffset / 1000).toFixed(1)} s` : "let the app correct this track's timing", !lyr.autoSyncOff, () => { void setAutoSync(lyr.autoSyncOff); })}
|
||
<div class="sheet-hair"></div>
|
||
{/if}
|
||
{@render row("search", "Wrong lyrics? Search again", "LRCLIB, by this track's name", () => { void searchCandidates(); })}
|
||
{@render row("file-text", "Paste lyrics…", "plain text, or LRC with [mm:ss] timestamps", () => openHalf("prompt", { title: "Paste lyrics", placeholder: "[00:12.50] The first line…", ok: "Save", multiline: true, onsubmit: (v) => { void pasteLyrics(v); } }))}
|
||
{#if lyr.lines.length || lyr.plain}
|
||
{@render row("clock", lyr.lines.length ? "Re-time by hand" : "Sync these lyrics", tapLeft ? `${tapLeft.done} of ${tapLeft.total} lines timed — keep going` : "play the song and tap as each line begins", act(openTapSync))}
|
||
{/if}
|
||
{#if canReduce}
|
||
{@render row("music", onReduced ? "Back to the original" : "Play with reduced vocals", onReduced ? "the recording as it is" : lyr.reducing ? "making the copy now — a moment" : "a rough centre cancel, made on this phone", act(() => { void (onReduced ? backToOriginal() : reduceVocals()); }))}
|
||
{/if}
|
||
{@render row("refresh", "Forget and look again", "", act(() => { void forgetLyrics(); }))}
|
||
</div>
|
||
{/if}
|
||
{: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 === "artist-more"}
|
||
{@const nm = top().c.name}
|
||
{@const ent = catalog.artists[artistKey(nm)] ?? null}
|
||
{@const pg = ent?.page ?? null}
|
||
{@const aid = pg?.artist?.id ?? ent?.id ?? null}
|
||
<div class="sheet-head">
|
||
<span class="art">{#if picture(pg?.artist?.picture)}<img src={picture(pg.artist.picture)} alt="" />{:else}<Icon name="users" size={22} />{/if}</span>
|
||
<span class="b"><span class="t">{nm}</span><span class="m">{pg?.albums?.length ? pg.albums.length + " releases on the catalog" : "Artist"}</span></span>
|
||
</div>
|
||
<div class="sheet-rows">
|
||
{#if aid != null}
|
||
{@render row("image", "Choose hero photo…", "a wide picture from this phone, 1920×800 or larger", act(() => heroFromPhoto(aid, nm).catch(oops)))}
|
||
{#if pg?.banner_custom}
|
||
{@render row("x", "Remove custom hero", "the catalog's own photos come back", act(() => heroClear(aid, nm).catch(oops)))}
|
||
{/if}
|
||
<div class="sheet-hair"></div>
|
||
{/if}
|
||
{@render row("settings", "Artwork settings", "covers and portraits, in You", act(() => go("you")))}
|
||
</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(); openHalf("peer", { username: u.username }); }}>
|
||
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
|
||
<span class="b"><span class="t">{display(u.username)}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{[realName(u.username), u.file_count ? u.file_count.toLocaleString() + " files" : ""].filter(Boolean).join(" · ")}</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>
|