android: the library's file stats on the cards, the artist's own plays, the thumbnail prewarm
The desktop Library's per-record file stats on the phone (library_album_stats, one call for the whole library): FLAC 24/96 or MP3 320k badges on the cards, the size on the list rows and the record page. The artist page's own listening (artist_play_stats): the time played as a chip, the most played track under "On this phone". The whole-library thumbnail warm a moment after boot, at the cards' edge, one decode at a time behind the tiles' gate.
This commit is contained in:
1 parent
26928b706d
commit
991d52d0cc
8 files changed
+63
-11
No files matched your search
+8
-2
@@ -208,8 +208,14 @@ Library clears the play history behind a confirm. The now-playing
|
||||
sheet's ⋯ on a streamed track is the desktop's
|
||||
Save menu: Save to library (at once when the cache is byte-complete, else
|
||||
through the download pipeline) and Save this record for the streamed
|
||||
record the queue holds. Search corrects an obvious misspelling from the
|
||||
names this phone knows (the library, the searches that worked) — the desktop's rule,
|
||||
record the queue holds. A moment after boot the phone warms the library's thumbnails the
|
||||
desktop's way (`library_art_prewarm` at the cards' edge, one decode at a
|
||||
time behind the tiles' gate). The Library's cards wear the desktop's file stats (`library_album_stats`,
|
||||
one call for the whole library: FLAC 24/96, MP3 320k, the size on the
|
||||
list rows and the record page), and the artist page shows this phone's
|
||||
own listening (`artist_play_stats`: time played as a chip, the most
|
||||
played track under "On this phone"). Search corrects an obvious
|
||||
misspelling from the names this phone knows (the library, the searches that worked) — the desktop's rule,
|
||||
a word only when a real name is within two edits.
|
||||
|
||||
## Playlists
|
||||
|
||||
@@ -446,6 +446,7 @@ function mockCatalog(cmd, args) {
|
||||
if (cmd === "catalog_artist_profile") return Promise.resolve({ listeners: 3_100_000, plays: 210_000_000, tags: ["electronic", "house", "french house", "dance"] });
|
||||
if (cmd === "catalog_artist_bio") return Promise.resolve("Daft Punk were a French electronic music duo formed in Paris in 1993 by Guy-Manuel de Homem-Christo and Thomas Bangalter. They achieved popularity in the late 1990s as part of the French house movement.");
|
||||
if (cmd === "catalog_related") return Promise.resolve(RELATED);
|
||||
if (cmd === "artist_play_stats") return Promise.resolve({ seconds: 7380, last_played_at: Math.floor(Date.now() / 1000) - 86400, top_title: "Track 1", top_count: 12 });
|
||||
if (cmd === "followed_artists") return Promise.resolve(follows.slice());
|
||||
if (cmd === "follow_artist") { follows.push({ artist_id: args?.artistId, name: args?.name, portrait: args?.portrait ?? null, followed_at: Date.now() / 1000 }); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); }
|
||||
if (cmd === "unfollow_artist") { follows = follows.filter((f) => f.artist_id !== args?.artistId); later(30, () => mockEmit("soulseek:follows-changed", {})); return Promise.resolve(); }
|
||||
@@ -483,6 +484,7 @@ function mockCatalog(cmd, args) {
|
||||
}
|
||||
if (cmd === "cancel_download") return Promise.resolve();
|
||||
if (cmd === "rescan_library") return Promise.resolve();
|
||||
if (cmd === "library_art_prewarm") return Promise.resolve();
|
||||
if (cmd === "refresh_all_album_art") return new Promise((r) => setTimeout(() => r(140), 300));
|
||||
if (cmd === "catalog_art") return Promise.resolve(svgArt(200, "D"));
|
||||
if (cmd === "stream_cache_ready") return Promise.resolve(cacheReady.has(args?.path) || (xfer && xfer.path === args?.path && xfer.received >= xfer.total));
|
||||
|
||||
@@ -185,6 +185,10 @@
|
||||
bindHome();
|
||||
bindImporter();
|
||||
bindShare();
|
||||
/* the desktop's library art prewarm: every cover cut to the cards'
|
||||
edge, one decode at a time behind the tiles' gate, a moment after
|
||||
boot so the first screen and a restored track come first */
|
||||
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
|
||||
/* the probe handle — the desktop has __soulstream for the same reason:
|
||||
tests/mobile/smoke.mjs drives the real app through it */
|
||||
window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, slsk, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, pls, notice, importer, next, prev, toggle, seekSecs, playQueueAt, patch };
|
||||
|
||||
@@ -9,6 +9,8 @@ export const lib = $state({
|
||||
recents: [], // library rows only, with an album_id
|
||||
follows: [],
|
||||
playlists: [],
|
||||
/** album id → { secs, bytes, avg_kbps, bit_depth, sample_rate, formats, last_played_at } — the desktop's library_album_stats */
|
||||
stats: {},
|
||||
version: "",
|
||||
username: null,
|
||||
loaded: false,
|
||||
@@ -104,6 +106,7 @@ export async function watchLibrary() {
|
||||
timer = setTimeout(async () => {
|
||||
try { lib.albums = await api.libraryAlbums(); } catch { /* keep */ }
|
||||
await refreshRecents();
|
||||
void refreshStats();
|
||||
}, 600);
|
||||
});
|
||||
/* a playlist mutation (the desktop's commands emit it) re-lists them */
|
||||
@@ -122,6 +125,24 @@ export async function loadLibrary() {
|
||||
try { lib.follows = (await api.followedArtists()) || []; } catch { lib.follows = []; }
|
||||
try { lib.playlists = (await api.listPlaylists()) || []; } catch { lib.playlists = []; }
|
||||
lib.loaded = true;
|
||||
void refreshStats();
|
||||
}
|
||||
|
||||
/** The files' own aggregates per record — one call for the whole library
|
||||
* (the desktop's RECENTS_FLOOR_SECS, 45, is the floor a play must reach
|
||||
* to count as the last one). */
|
||||
export async function refreshStats() {
|
||||
try {
|
||||
const rows = (await api.libraryAlbumStats(45)) || [];
|
||||
lib.stats = Object.fromEntries(rows.map((r) => [r.album_id, r]));
|
||||
} catch { /* the cards stay quiet */ }
|
||||
}
|
||||
/** "FLAC 24/96" for a lossless record, "MP3 320k" for a lossy one. */
|
||||
export function statLabel(st) {
|
||||
if (!st || !st.formats?.length) return "";
|
||||
const f = String(st.formats[0]).toUpperCase();
|
||||
if (st.bit_depth && st.sample_rate) return f + " " + st.bit_depth + "/" + (st.sample_rate % 1000 === 0 ? st.sample_rate / 1000 : (st.sample_rate / 1000).toFixed(1));
|
||||
return st.avg_kbps ? f + " " + st.avg_kbps + "k" : f;
|
||||
}
|
||||
|
||||
export async function refreshPlaylists() {
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Cover from "../Cover.svelte";
|
||||
import Release from "../Release.svelte";
|
||||
import { albumById, albumsBy, tracksOf } from "../lib.svelte.js";
|
||||
import { lib, albumById, albumsBy, tracksOf } from "../lib.svelte.js";
|
||||
import { player, playAlbum, playAlbumShuffled } from "../player.svelte.js";
|
||||
import { push, openHalf, openViewer } from "../nav.svelte.js";
|
||||
import { mmss, longDur, albumFmt } from "../util.js";
|
||||
import { mmss, longDur, albumFmt, fmtBytes } from "../util.js";
|
||||
|
||||
let { id, screenEl = null } = $props();
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
{#if fmt}
|
||||
<div class="stats rec-chips">
|
||||
<span class="s"><Icon name="disc" size={12} strokeWidth={3} />{fmt}</span>
|
||||
<span class="s"><Icon name="hard-drive" size={12} strokeWidth={3} />in your library</span>
|
||||
<span class="s"><Icon name="hard-drive" size={12} strokeWidth={3} />{lib.stats[album.id]?.bytes ? fmtBytes(lib.stats[album.id].bytes) : "in your library"}</span>
|
||||
{#if lib.stats[album.id]?.avg_kbps}<span class="s">{lib.stats[album.id].avg_kbps} kbps</span>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="rec-acts">
|
||||
|
||||
@@ -22,12 +22,25 @@
|
||||
import { catalog, lookupArtist, artistKey, fans, yearOf, looseTitle, loadExtras, picture } from "../catalog.svelte.js";
|
||||
import { resolver, streamSong, streamCatalogAlbum } from "../resolve.svelte.js";
|
||||
import { discoTabs, activeYears, latestOf, timelineOf, isUpcoming, SOCIAL_LABEL } from "../disco.js";
|
||||
import { shuffled, mmss } from "../util.js";
|
||||
import { shuffled, mmss, ago } from "../util.js";
|
||||
|
||||
/** `seed`: the search's own row (a CatalogArtist), when the page was
|
||||
* opened from one — the identity is then known, no name search. */
|
||||
let { name, seed = null, screenEl = null } = $props();
|
||||
|
||||
/* the desktop's play-stats chips: this phone's own listening to the artist */
|
||||
let plays = $state({ key: "", seconds: 0, last: null, topTitle: null, topCount: 0 });
|
||||
$effect(() => {
|
||||
const n = name;
|
||||
if (plays.key === n) return;
|
||||
plays = { key: n, seconds: 0, last: null, topTitle: null, topCount: 0 };
|
||||
api.artistPlayStats(n).then((st) => {
|
||||
if (plays.key !== n) return;
|
||||
plays = { key: n, seconds: st?.seconds ?? 0, last: st?.last_played_at ?? null, topTitle: st?.top_title ?? null, topCount: st?.top_count ?? 0 };
|
||||
}, () => { /* the chips stay hidden */ });
|
||||
});
|
||||
const playTime = $derived(!plays.seconds || plays.seconds < 60 ? "" : plays.seconds < 3600 ? Math.round(plays.seconds / 60) + " min" : (plays.seconds / 3600).toFixed(1) + " hours");
|
||||
|
||||
/* ── the library's shell ─────────────────────────────────────────── */
|
||||
const albums = $derived(albumsBy(name));
|
||||
const portrait = $derived(albums.find((a) => a.artist_art_path)?.artist_art_path || null);
|
||||
@@ -198,6 +211,7 @@
|
||||
{#if page?.artist?.fans || seed?.fans}<span class="s"><Icon name="users" size={12} strokeWidth={3} />{fans(page?.artist?.fans || seed?.fans)}</span>{/if}
|
||||
{#if years}<span class="s"><Icon name="calendar" size={12} strokeWidth={3} />{years}</span>{/if}
|
||||
{#if page?.top_genre}<span class="s"><Icon name="tag" size={12} strokeWidth={3} />{page.top_genre}</span>{/if}
|
||||
{#if playTime}<span class="s played"><Icon name="history" size={12} strokeWidth={3} />{playTime} played</span>{/if}
|
||||
{#if albums.length}<button class="s quiet" onclick={shuffleAll}><Icon name="shuffle" size={12} strokeWidth={3} />{albums.length} here · shuffle</button>{/if}
|
||||
{#each socials as link (link.kind + link.url)}
|
||||
<button class="s quiet soc" onclick={() => openLink(link.url)}><Icon name={link.kind === "website" ? "globe" : "link"} size={12} strokeWidth={3} />{SOCIAL_LABEL[link.kind] || link.kind}</button>
|
||||
@@ -303,6 +317,7 @@
|
||||
|
||||
{#if albums.length}
|
||||
<div class="sec"><h2>On this phone <em>· {albums.length} record{albums.length === 1 ? "" : "s"}{tracks ? ` · ${tracks} tracks` : ""}</em></h2></div>
|
||||
{#if plays.topTitle}<div class="sec-note">Most played here: {plays.topTitle} · {plays.topCount}×{plays.last ? " · last " + ago(plays.last) : ""}</div>{/if}
|
||||
<div class="grid2">
|
||||
{#each albums as a (a.id)}
|
||||
<Release title={a.title} sub={String(a.year || "")} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} />
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
import Tracks from "./Tracks.svelte";
|
||||
import PlaylistShelf from "../PlaylistShelf.svelte";
|
||||
import * as api from "../../lib/api.js";
|
||||
import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js";
|
||||
import { lib, artists, isFollowed, followedEntry, statLabel } from "../lib.svelte.js";
|
||||
import { fmtBytes } from "../util.js";
|
||||
import { push, go, patch, openHalf } from "../nav.svelte.js";
|
||||
import { looseTitle, yearOf } from "../catalog.svelte.js";
|
||||
import { isUpcoming } from "../disco.js";
|
||||
@@ -110,14 +111,14 @@
|
||||
{:else if view === "grid"}
|
||||
<div class="grid2">
|
||||
{#each list as a (a.id)}
|
||||
<Release title={a.title} sub={sort === "year" ? String(a.year || "") : a.artist} path={a.art_path} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
|
||||
<Release title={a.title} sub={sort === "year" ? String(a.year || "") : a.artist} path={a.art_path} badge={statLabel(lib.stats[a.id]) || null} onclick={() => push("album", { id: a.id }, screenEl)} onmore={() => openHalf("album", { album: a })} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#each list as a (a.id)}
|
||||
<button class="row" onclick={() => push("album", { id: a.id }, screenEl)} oncontextmenu={(e) => { e.preventDefault(); openHalf("album", { album: a }); }}>
|
||||
<span class="art"><Cover path={a.art_path} size={20} /></span>
|
||||
<span class="b"><span class="t">{a.title}</span><span class="m">{a.artist} · {a.year ?? "—"}</span></span>
|
||||
<span class="b"><span class="t">{a.title}</span><span class="m">{a.artist} · {a.year ?? "—"}{statLabel(lib.stats[a.id]) ? " · " + statLabel(lib.stats[a.id]) : ""}{lib.stats[a.id]?.bytes ? " · " + fmtBytes(lib.stats[a.id].bytes) : ""}</span></span>
|
||||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
@@ -122,6 +122,8 @@ const PROBE = `(function () {
|
||||
brwDirs: document.querySelectorAll('.brw-dir').length,
|
||||
qrows: document.querySelectorAll('.q-face .row').length,
|
||||
viewer: !!document.querySelector('.viewer img'),
|
||||
fmtBadges: document.querySelectorAll('.grid2 .release .ed-badge').length,
|
||||
played: (document.querySelector('.hero-acts .stats .played') || {}).textContent || '',
|
||||
lyrLines: document.querySelectorAll('.lyr .ln').length,
|
||||
sheetTitle: (document.querySelector('.halfsheet .sheet-title') || {}).textContent || '',
|
||||
recents: document.querySelectorAll('.recent-chips .chip').length,
|
||||
@@ -386,7 +388,7 @@ const MOCK_EXPECT = {
|
||||
"search-dym": (i) => i.dym === "shpongle",
|
||||
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
|
||||
search: (i) => i.title.startsWith("Search"),
|
||||
library: (i) => i.title.startsWith("Your library"),
|
||||
library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10,
|
||||
album: (i) => i.screen.includes("tr-push") && !i.mini,
|
||||
lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3,
|
||||
"lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText),
|
||||
@@ -399,7 +401,7 @@ const MOCK_EXPECT = {
|
||||
"track-sheet": (i) => i.half.includes("up"),
|
||||
sheet: (i) => i.sheet.includes("up"),
|
||||
"after-back": (i) => !i.sheet.includes("up"),
|
||||
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img"),
|
||||
artist: (i) => i.screen.includes("has-hero") && i.amb.includes("on:img") && /played/.test(i.played),
|
||||
queue: (i) => { globalThis.__qrows = i.qrows; return i.sheet.includes("up") && i.qrows >= 2; },
|
||||
"queue-row-sheet": (i) => i.half.includes("up") && /Remove from queue/.test(i.halfText) && /Play now/.test(i.halfText),
|
||||
"queue-removed": (i) => !i.half.includes("up") && i.qrows === globalThis.__qrows - 1,
|
||||
|
||||
Reference in new issue
Block a user