android: hours on the library's count line, By format, Jump back in on the empty search

The desktop Library summary's hours on the phone's count line; a By format
order from the file stats (lossless by depth and rate first, then by
bitrate, the badge as the card's line); the empty Search page's Jump back
in — the last records played — as the desktop's empty search shows them.
This commit is contained in:
KeelrLeek committed 2026-09-13 21:31:16 -05:00
1 parent b50577fc81
commit f8c3b90665
6 files changed
+39 -8

No files matched your search

+3 -1
View File
@@ -213,7 +213,9 @@ 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 through the download pipeline) and Save this record for the streamed
record the queue holds. A moment after boot the phone warms the library's thumbnails the 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 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`, time behind the tiles' gate). The Library's count line carries the collection's hours and the sort
offers By format (lossless by depth and rate first, then by bitrate); the
empty Search page shows Jump back in, the desktop's. 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 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 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 own listening (`artist_play_stats`: time played as a chip, the most
+1 -1
View File
@@ -325,7 +325,7 @@ export function mockInvoke(cmd, args) {
if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null }); if (cmd === "app_status") return Promise.resolve({ version: "mock", connected_as: null });
if (cmd === "library_albums") return Promise.resolve(albums); if (cmd === "library_albums") return Promise.resolve(albums);
if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor)); if (cmd === "library_album_stats") return Promise.resolve(albums.map(statsFor));
if (cmd === "recent_albums") return Promise.resolve([]); if (cmd === "recent_albums") return Promise.resolve(albums.slice(0, 3).map((a, i) => ({ kind: "library", album_id: a.id, title: a.title, artist: a.artist, art_path: a.art_path, last_played_at: Math.floor(Date.now() / 1000) - 3600 * (i + 1) })));
if (cmd === "album_tracks") if (cmd === "album_tracks")
return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60)); return new Promise((r) => setTimeout(() => r(tracksFor(args.albumId)), 60));
if (cmd === "album_art") { if (cmd === "album_art") {
+1 -1
View File
@@ -21,7 +21,7 @@
import { note } from "./notice.svelte.js"; import { note } from "./notice.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.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"]]; 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 */ /* the last sheet is kept through its slide down */
let shown = $state(null); let shown = $state(null);
+17 -3
View File
@@ -18,7 +18,7 @@
let { entry, screenEl = null } = $props(); let { entry, screenEl = null } = $props();
const TABS = [["albums", "Albums"], ["artists", "Artists"], ["tracks", "Tracks"], ["playlists", "Playlists"], ["gaps", "Gaps"]]; const TABS = [["albums", "Albums"], ["artists", "Artists"], ["tracks", "Tracks"], ["playlists", "Playlists"], ["gaps", "Gaps"]];
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]]; const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"], ["format", "By format"]];
const tab = $derived(entry.c.tab || "albums"); const tab = $derived(entry.c.tab || "albums");
const sort = $derived(entry.c.sort || "added"); const sort = $derived(entry.c.sort || "added");
@@ -30,11 +30,25 @@
if (sort === "az") return x.title.localeCompare(y.title); if (sort === "az") return x.title.localeCompare(y.title);
if (sort === "artist") return x.artist.localeCompare(y.artist) || (x.year || 0) - (y.year || 0); if (sort === "artist") return x.artist.localeCompare(y.artist) || (x.year || 0) - (y.year || 0);
if (sort === "year") return (y.year || 0) - (x.year || 0) || y.added_at - x.added_at; if (sort === "year") return (y.year || 0) - (x.year || 0) || y.added_at - x.added_at;
if (sort === "format") {
/* lossless first, then the higher bitrate, then the name */
const fx = fmtRank(lib.stats[x.id]);
const fy = fmtRank(lib.stats[y.id]);
return fy - fx || x.title.localeCompare(y.title);
}
return y.added_at - x.added_at; return y.added_at - x.added_at;
}); });
return l; return l;
}); });
const totalTracks = $derived(lib.albums.reduce((n, a) => n + (a.track_count || 0), 0)); const totalTracks = $derived(lib.albums.reduce((n, a) => n + (a.track_count || 0), 0));
const totalSecs = $derived(Object.values(lib.stats).reduce((n, st) => n + (st.secs || 0), 0));
const hours = $derived(totalSecs >= 3600 ? Math.round(totalSecs / 3600) + " hours" : totalSecs ? Math.round(totalSecs / 60) + " min" : "");
/* By format: a number the sort can compare — lossless by depth and rate, lossy by bitrate */
function fmtRank(st) {
if (!st || !st.formats?.length) return 0;
if (st.bit_depth && st.sample_rate) return 1_000_000 + st.bit_depth * 1000 + Math.round(st.sample_rate / 1000);
return st.avg_kbps || 1;
}
const arts = $derived(artists()); const arts = $derived(artists());
/* ── Gaps — the desktop's (Library.svelte's gapSection): only a shelf /* ── Gaps — the desktop's (Library.svelte's gapSection): only a shelf
@@ -101,7 +115,7 @@
{/each} {/each}
</div> </div>
<div class="countline"> <div class="countline">
<span class="t-sub">{list.length} album{list.length === 1 ? "" : "s"} · {totalTracks} tracks</span> <span class="t-sub">{list.length} album{list.length === 1 ? "" : "s"} · {totalTracks} tracks{hours ? " · " + hours : ""}</span>
<button class="iconbtn sm muted" onclick={() => patch({ view: view === "grid" ? "list" : "grid" })} aria-label="Grid or list"> <button class="iconbtn sm muted" onclick={() => patch({ view: view === "grid" ? "list" : "grid" })} aria-label="Grid or list">
<Icon name={view === "grid" ? "playlist" : "layers"} size={18} /> <Icon name={view === "grid" ? "playlist" : "layers"} size={18} />
</button> </button>
@@ -111,7 +125,7 @@
{:else if view === "grid"} {:else if view === "grid"}
<div class="grid2"> <div class="grid2">
{#each list as a (a.id)} {#each list as a (a.id)}
<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 })} /> <Release title={a.title} sub={sort === "year" ? String(a.year || "") : sort === "format" ? (statLabel(lib.stats[a.id]) || a.artist) : 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} {/each}
</div> </div>
{:else} {:else}
+8
View File
@@ -292,6 +292,14 @@
Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page. Artists, records and songs from the catalog; a song plays from the best copy the network has, a record opens its page.
{#if !online}Sign in on Community to play what the library does not hold.{/if} {#if !online}Sign in on Community to play what the library does not hold.{/if}
</div> </div>
{#if lib.recents.length}
<div class="sec"><h2>Jump back in <em>· picks up where you left off</em></h2></div>
<div class="rail">
{#each lib.recents.slice(0, 8) as r (r.album_id)}
<Release title={r.title} sub={r.artist} path={r.art_path} onclick={() => push("album", { id: r.album_id }, screenEl)} />
{/each}
</div>
{/if}
{#if recents.length} {#if recents.length}
<div class="sec"><h2>Recent</h2><button class="all" onclick={() => { recents = []; keepRecents(); }}>Clear</button></div> <div class="sec"><h2>Recent</h2><button class="all" onclick={() => { recents = []; keepRecents(); }}>Clear</button></div>
<div class="chips recent-chips" style="padding-bottom: 12px"> <div class="chips recent-chips" style="padding-bottom: 12px">
+9 -2
View File
@@ -114,6 +114,9 @@ const PROBE = `(function () {
plcover: !!document.querySelector('.rec-head.pl .rec-cover img'), plcover: !!document.querySelector('.rec-head.pl .rec-cover img'),
qfirst: (document.querySelector('.q-face .row .t') || {}).textContent || '', qfirst: (document.querySelector('.q-face .row .t') || {}).textContent || '',
rooms: document.querySelectorAll('.screen button.row .art.hash').length, rooms: document.querySelectorAll('.screen button.row .art.hash').length,
jump: document.querySelectorAll('.screen .rail .release').length,
countline: (document.querySelector('.countline .t-sub') || {}).textContent || '',
firstBadge: (document.querySelector('.grid2 .release .ed-badge') || {}).textContent || '',
plthumbs: document.querySelectorAll('.pl-rows .thumb img').length, plthumbs: document.querySelectorAll('.pl-rows .thumb img').length,
imp: document.querySelectorAll('.imp-rows .trk').length, imp: document.querySelectorAll('.imp-rows .trk').length,
impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'), impTags: [].map.call(document.querySelectorAll('.imp-rows .tag'), function (t) { return t.textContent.trim(); }).join('|'),
@@ -164,6 +167,9 @@ const MOCK_STEPS = [
{ do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpongel'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search-dym", wait: 900 }, { do: `(function () { var i = document.querySelector('.sfield input'); i.value = 'shpongel'; i.dispatchEvent(new Event('input', { bubbles: true })); })()`, name: "search-dym", wait: 900 },
{ do: `__click('.dym', null)`, name: "search-dym-pick", wait: 700 }, { do: `__click('.dym', null)`, name: "search-dym-pick", wait: 700 },
{ do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 }, { do: `document.querySelectorAll('.nav button')[2].click()`, name: "library", wait: 800 },
/* By format: the hi-res lossless records come first */
{ do: `__click('.chips .chip', 'By format')`, name: "library-by-format", wait: 500 },
{ do: `__click('.chips .chip', 'Recently added')`, wait: 400 },
{ do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 }, { do: `document.querySelectorAll('.segtabs button')[1].click()`, name: "artists", wait: 600 },
{ do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 }, { do: `document.querySelectorAll('.segtabs button')[2].click()`, name: "tracks", wait: 900 },
{ do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 300 }, { do: `document.querySelectorAll('.segtabs button')[0].click()`, wait: 300 },
@@ -398,7 +404,7 @@ const MOCK_EXPECT = {
"you-artedge": (i) => i.artEdge === "1000 px", "you-artedge": (i) => i.artEdge === "1000 px",
"history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText), "history-confirm": (i) => i.half.includes("up") && /Clear the play history/.test(i.halfText),
"history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice), "history-cleared": (i) => !i.half.includes("up") && /history cleared/i.test(i.notice),
"search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1, "search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1 && i.jump >= 1,
"search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top), "search-recent-pick": (i) => i.title.startsWith("Search") && (i.songs >= 4 || !!i.top),
"sleep-sheet": (i) => i.half.includes("up"), "sleep-sheet": (i) => i.half.includes("up"),
"sleep-set": (i) => !i.half.includes("up"), "sleep-set": (i) => !i.half.includes("up"),
@@ -409,7 +415,7 @@ const MOCK_EXPECT = {
"search-dym": (i) => i.dym === "shpongle", "search-dym": (i) => i.dym === "shpongle",
"search-dym-pick": (i) => i.sq === "shpongle" && !i.dym, "search-dym-pick": (i) => i.sq === "shpongle" && !i.dym,
search: (i) => i.title.startsWith("Search"), search: (i) => i.title.startsWith("Search"),
library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10, library: (i) => i.title.startsWith("Your library") && i.fmtBadges >= 10 && /hours/.test(i.countline),
album: (i) => i.screen.includes("tr-push") && !i.mini, album: (i) => i.screen.includes("tr-push") && !i.mini,
lyrics: (i) => i.sheet.includes("up") && i.lyrLines >= 3, 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), "lyrics-sheet": (i) => i.half.includes("up") && /Lyrics later/.test(i.halfText) && /Search again/.test(i.halfText),
@@ -464,6 +470,7 @@ const MOCK_EXPECT = {
gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1, gaps: (i) => i.title.startsWith("Your library") && i.gaplines >= 1,
"gaps-open": (i) => i.gapCards >= 3, "gaps-open": (i) => i.gapCards >= 3,
playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned, playlists: (i) => i.title.startsWith("Your library") && i.plcards === 3 && i.plpinned,
"library-by-format": (i) => /^FLAC 24/.test(i.firstBadge),
artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 3, artists: (i) => i.title.startsWith("Your library") && i.rowArt >= 3,
playlist: (i) => i.screen.includes("tr-push") && i.title.startsWith("Late night drive") && i.plrows === 6 && i.plthumbs >= 3 && /unresolved/.test(i.pltags) && /file missing/.test(i.pltags) && /^Playlist/.test(i.plkind) && /Pinned/.test(i.plkind) && i.plfilter, playlist: (i) => i.screen.includes("tr-push") && i.title.startsWith("Late night drive") && i.plrows === 6 && i.plthumbs >= 3 && /unresolved/.test(i.pltags) && /file missing/.test(i.pltags) && /^Playlist/.test(i.plkind) && /Pinned/.test(i.plkind) && i.plfilter,
"playlist-playing": (i) => i.mini && i.playing.includes("Track 2") && !i.toast, "playlist-playing": (i) => i.mini && i.playing.includes("Track 2") && !i.toast,