{#snippet row(icon, t, m, fn)} {/snippet} {#snippet swrow(icon, t, m, on, fn)} {/snippet}
{#if kind === "track" && trackArg} {@const album = trackArg.album} {@const track = trackArg.track}
{#if isCatalog(track) && (track.catalog?.cover || album?.art_path)}{:else}{/if} {track.title}{track.artist || album?.artist} · {album?.title}
{#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)))}
{/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 }] }))}
{#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}
{/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 }] }))}
{#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}
{#if !isCatalog(track)}
Format{fmtOf(track) || "—"}
{#if track.size}
Size{fmtBytes(track.size)}
{/if}
File{basename(track.path)}
Folder{dirname(track.path)}
{/if} {:else if (kind === "album" || kind === "album-more") && albumArg} {@const album = albumArg.album} {@const tracks = albumArg.tracks || []}
{album.title}{album.artist} · {album.year ?? "—"} · {tracks.length || album.track_count} tracks
{@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}
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))} {#if album.id != null}
{@render row("image", "Re-fetch the cover", "the canonical high-resolution cover, into the folder and the tags", act(() => refetchCover(album)))} {/if}
Format{albumFmt(tracks) || "—"}
Length{longDur(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0))}
{#if tracks[0]?.path}
Folder{dirname(tracks[0].path)}
{/if}
{:else if (kind === "playlist" || kind === "playlist-more") && plArg} {@const p = plArg.playlist} {@const rows = plArg.items || []} {@const unres = rows.filter((i) => i.kind === "unresolved")}
{#if coverFor(p)}{:else}{/if} {p.name}{p.kind === "setlist" ? "Setlist" : "Playlist"} · {p.track_count} track{p.track_count === 1 ? "" : "s"}{p.duration_secs ? " · " + longDur(p.duration_secs) : ""}{p.pinned ? " · pinned" : ""}
{#if kind === "playlist"} {@render row("play", "Play", "", act(() => playPl(p)))} {@render row("shuffle", "Shuffle", "", act(() => playPl(p, true)))}
{/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)))}
{@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}
{@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}
{@render row("trash", "Delete playlist", "", () => confirmDelete(p))}
{:else if kind === "pltrack" && shown?.arg?.item} {@const p = shown.arg.playlist} {@const rows = shown.arg.items || []} {@const item = shown.arg.item}
{#if item.path && item.art_path}{:else if item.art_url}{:else}{/if} {item.title}{item.artist}{item.album ? " · " + item.album : ""}{item.kind === "unresolved" ? " · unresolved" : item.missing ? " · file missing" : item.kind === "stream" ? " · streams from the network" : ""}
{#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"}
This row never matched a track{item.source_line ? " — it came in as “" + item.source_line + "”" : ""}. Repair it on the desktop, or remove it.
{:else if item.missing}
The file is no longer where the library last saw it.
{/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)}
{#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}
{@render row("x", "Remove from playlist", "undo for a few seconds after", act(() => removeItems(p, rows, [item]).catch(oops)))}
{:else if kind === "pickPlaylist" && shown?.arg?.entries} {@const entries = shown.arg.entries}
Add to playlist · {entries.length === 1 ? entries[0].t.title : entries.length + " tracks"}
{@render row("plus", "New playlist", "", () => newPlaylistThenAdd(entries))}
{#each lib.playlists as p (p.id)} {/each} {#if !lib.playlists.length}
No playlists yet — make one above.
{/if}
{:else if kind === "prompt" && shown?.arg} {@const a = shown.arg}
{a.title}
{#if a.multiline} {:else} { if (e.key === "Enter") submitPrompt(); }} use:focusLater /> {/if}
{:else if kind === "confirm" && shown?.arg} {@const a = shown.arg}
{a.title}
{#if a.body}
{a.body}
{/if}
{:else if kind === "import"}
Import a playlist
{#each DOORS as [door, icon, t, m] (door)} {@render row(icon, t, m, () => (door === "folder" ? folderDoor() : importPrompt(door)))} {/each}
A playlist file — M3U, XSPF, PLS or CSV — imports on the desktop; the phone has no file picker for one yet.
{:else if kind === "plmatch" && shown?.arg?.item} {@const p = shown.arg.playlist} {@const item = shown.arg.item}
Find a local match · {item.title}
{ matchQ = e.currentTarget.value; void runMatch(); }} type="search" placeholder="Search your library" autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Search your library" />
{#if matchBusy && !matchRows.length}
Searching…
{:else if !matchRows.length}
No library tracks match. A named row still streams from the network when you tap it.
{:else} {#each matchRows.slice(0, 30) as c (c.path)} {/each} {/if}
{: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)}
{display(u)}{#if m.favorite}{/if}{#if m.trusted}{/if}{[realName(u), m.group, g ? (g.slot ? "slot free" : "queue " + g.queue) + " · " + speedLabel(g.speed) + " · " + g.folderName : "on Soulseek"].filter(Boolean).join(" · ")}
{#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)))}
{/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)))}
{@render row("pencil", "Name and notes", "a petname, a group, a note — this phone only", () => openHalf("contact", { username: u }))}
{:else if kind === "contact" && shown?.arg?.username} {@const u = shown.arg.username} {@const m = metaOf(u)}
{display(u)}{realName(u) ? realName(u) + " · " : ""}kept on this phone, published nowhere
{@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))}
{#if m.petname || m.group || m.note || m.trusted || m.favorite}
{@render row("x", "Clear these details", "everything above leaves the list", act(() => { void clearMeta(u); }))} {/if}
{:else if (kind === "catalogAlbum-more" || kind === "catalogAlbum") && catPage} {@const al = catPage.album} {@const tracks = catPage.tracks || []}
{#if al.cover}{:else}{/if} {al.title}{al.artist} · {yearOf(al.release_date) ?? "—"} · {tracks.length || al.track_count || "?"} tracks
{@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}
{#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 })))}
{:else if kind === "peerfile" && shown?.arg?.group} {@const g = shown.arg.group} {@const i = shown.arg.index || 0} {@const f = g.files[i]}
{f ? titleFromName(f.name) : "—"}{g.username} · {g.folderName}{f ? " · " + (f.bitrate ? f.bitrate + " kbps" : (f.extension || f.ext || "").toUpperCase()) + " · " + fmtBytes(f.size) : ""}
{@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}
{@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))}
{:else if kind === "lyrics"}
Lyrics · {lyr.status === "loading" ? "looking" : lyr.source || lyr.status}{lyr.offset ? ` · ${lyr.offset > 0 ? "+" : ""}${(lyr.offset / 1000).toFixed(1)} s` : ""}
{#if lyr.candidates}
Choose the right lyrics
{#if lyr.searching}
Searching LRCLIB…
{:else if !lyr.candidates.length}
Nothing found for this track's name.
{:else} {#each lyr.candidates as c (c.id)} {/each} {/if}
{:else}
{#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); })}
{/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(); }))}
{/if} {:else if kind === "librarysort"}
Sort & view
Order
{#each SORTS as [k, l] (k)} {/each}
View
{#each [["grid", "Grid"], ["list", "List"]] as [k, l] (k)} {/each}
{:else if kind === "library-more"}
Library
{@render row("layers", "Sort and view", "", () => openHalf("librarysort"))} {@render row("playlist", "Playlists", "", act(() => patch({ tab: "playlists" })))}
{@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))}
{: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}
{#if picture(pg?.artist?.picture)}{:else}{/if} {nm}{pg?.albums?.length ? pg.albums.length + " releases on the catalog" : "Artist"}
{#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}
{/if} {@render row("settings", "Artwork settings", "covers and portraits, in You", act(() => go("you")))}
{:else if kind === "home-more"}
SoulStream
{#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")))}
{:else if kind === "community"}
Community
{@render row("refresh", "Refresh the rooms", slsk.rooms.length + " on the server", act(refreshRooms))} {@render row("x", "Sign out", slsk.username, act(disconnect))}
Spaces, holdings and the sealed inbox wait for the private layer on the phone.
{:else if kind === "roomusers" && shown?.arg?.name} {@const room = slsk.joined[shown.arg.name]}
#{shown.arg.name} · {room?.users.length ?? 0} here
{#each (room?.users ?? []).slice(0, 120) as u (u.username)} {@const buddy = slsk.buddies.includes(u.username)} {/each}
{:else if kind === "sleep"}
Sleep timer
Pause after
{#each [[0, "Off"], [15, "15 min"], [30, "30 min"], [45, "45 min"], [60, "1 hour"], [90, "1½ hours"]] as [m, l] (m)} {/each}
{#if player.sleepAt}
Playback pauses in {sleepLabel().replace(" left", "")}.
{/if} {/if}