android: the phone, on the real core — the mockup shell, playback, the media card, the catalog, the resolver, Save, the dev flavour
The same Tauri 2 project builds for Android: mobile.html as a third Vite
entry (src/mobile/, the mockup on real data), tauri.android.conf.json, the
mobile stubs (updater, lyric alignment, vocal separation), rustls reqwest
on Android, the AAudio buffer, and a hand-driven detour build
(src-tauri/android/{env,build,deploy,dev}.sh) while Developer Mode is off.
ANDROID.md is the recipe.
Playback: the media-session plugin crate (lock-screen card, MediaStyle
notification, audio focus, headset buttons, a foreground service that
promotes itself first — Android 14 kills the app otherwise), session
restore across reload and restart, solid chrome after measuring the
backdrop-filter lag.
The catalog: Search is the desktop's top bar (the top-result rule, artist
cards), the artist page and the record page are the desktop's, Home is the
desktop's landing page, Gaps per followed shelf. Playing goes through the
desktop's own source rules, moved verbatim from state.svelte.js into
src/lib/sources.js so both apps share one resolver; the phone readies the
next three tracks and crosses boundaries gapless. Save fetches a record or
a track into the library by the download rules; Transfers shows the
burst. The raw Soulseek search stays, offered on request, a peer's copy
streaming with the lane rising as the bytes arrive. Community is the
Soulseek side (rooms, chat, buddies); the private layer waits.
The opener and storage plugins (social links, the picked library folder,
all-files access), the volume normalization switch, and the harness:
tests/mobile/smoke.mjs walks the mock (a fake network behind
src/lib/events.js) and the real phone over CDP, perf.mjs measures frames.
This commit is contained in:
1 parent
972dd103cb
commit
bb828ff2dc
102 files changed
+12258
-830
No files matched your search
@@ -0,0 +1,187 @@
|
||||
<script>
|
||||
/* Your library — five second-tier tabs; Albums, Artists and Playlists
|
||||
are real. The sort and the view live on the tab's root entry, so they
|
||||
survive a push and a pop. */
|
||||
import Icon from "../../components/Icon.svelte";
|
||||
import Release from "../Release.svelte";
|
||||
import Cover from "../Cover.svelte";
|
||||
import Tracks from "./Tracks.svelte";
|
||||
import * as api from "../../lib/api.js";
|
||||
import { lib, artists, isFollowed, followedEntry } from "../lib.svelte.js";
|
||||
import { push, go, patch, openHalf } from "../nav.svelte.js";
|
||||
import { looseTitle, yearOf } from "../catalog.svelte.js";
|
||||
import { isUpcoming } from "../disco.js";
|
||||
import { longDur } from "../util.js";
|
||||
|
||||
let { entry, screenEl = null } = $props();
|
||||
|
||||
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 tab = $derived(entry.c.tab || "albums");
|
||||
const sort = $derived(entry.c.sort || "added");
|
||||
const view = $derived(entry.c.view || "grid");
|
||||
|
||||
const list = $derived.by(() => {
|
||||
const l = lib.albums.slice();
|
||||
l.sort((x, y) => {
|
||||
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 === "year") return (y.year || 0) - (x.year || 0) || y.added_at - x.added_at;
|
||||
return y.added_at - x.added_at;
|
||||
});
|
||||
return l;
|
||||
});
|
||||
const totalTracks = $derived(lib.albums.reduce((n, a) => n + (a.track_count || 0), 0));
|
||||
const arts = $derived(artists());
|
||||
|
||||
/* ── Gaps — the desktop's (Library.svelte's gapSection): only a shelf
|
||||
with an ANCHORED identity — a follow — is asked, so a name can never
|
||||
be guessed onto a namesake; the catalog's released records minus the
|
||||
ones on the shelf, by loose title; the totals remembered so the line
|
||||
can say its number before the lookup. */
|
||||
const GAPS_KEY = "library.gaps.v1";
|
||||
const foldKey = (x) => String(x ?? "").toLowerCase();
|
||||
let gapTotals = $state((() => { try { return JSON.parse(localStorage.getItem(GAPS_KEY)) ?? {}; } catch { return {}; } })());
|
||||
let gapRows = $state({}); // foldKey(artist) → catalog rows not owned
|
||||
let gapOpen = $state({});
|
||||
let gapLoading = $state({});
|
||||
const shelves = $derived(arts.filter((ar) => followedEntry(ar.name)));
|
||||
const ownedTitles = (name) => new Set(lib.albums.filter((a) => foldKey(a.artist) === foldKey(name)).map((a) => looseTitle(a.title)));
|
||||
async function toggleGaps(name) {
|
||||
const key = foldKey(name);
|
||||
if (gapOpen[key]) { gapOpen = { ...gapOpen, [key]: false }; return; }
|
||||
const follow = followedEntry(name);
|
||||
if (!follow) return;
|
||||
if (!gapRows[key] && !gapLoading[key]) {
|
||||
gapLoading = { ...gapLoading, [key]: true };
|
||||
try {
|
||||
const page = await api.catalogArtist(follow.artist_id);
|
||||
const released = (page?.albums ?? []).filter((a) => !isUpcoming(a) && !a.appears_on);
|
||||
const owned = ownedTitles(name);
|
||||
gapRows = { ...gapRows, [key]: released.filter((r) => !owned.has(looseTitle(r.title))) };
|
||||
gapTotals = { ...gapTotals, [key]: { total: released.length } };
|
||||
try { localStorage.setItem(GAPS_KEY, JSON.stringify(gapTotals)); } catch { /* ignore */ }
|
||||
} catch { /* the line stays collapsed; nothing is invented */ }
|
||||
gapLoading = { ...gapLoading, [key]: false };
|
||||
}
|
||||
if (gapRows[key]) gapOpen = { ...gapOpen, [key]: true };
|
||||
}
|
||||
const gapCount = (ar) => {
|
||||
const key = foldKey(ar.name);
|
||||
const missing = gapRows[key];
|
||||
const total = gapTotals[key]?.total;
|
||||
return missing ? missing.length : (total ? Math.max(0, total - ar.albums) : null);
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="phead">
|
||||
<div class="row">
|
||||
<h1>Your library</h1>
|
||||
<div class="acts">
|
||||
<button class="iconbtn" onclick={() => go("search")} aria-label="Search"><Icon name="search" size={21} /></button>
|
||||
<button class="iconbtn" onclick={() => patch({ tab: "playlists" })} aria-label="Playlists"><Icon name="playlist" size={21} /></button>
|
||||
<button class="iconbtn" onclick={() => openHalf("librarysort")} aria-label="Sort and filter"><Icon name="layers" size={21} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="segtabs">
|
||||
{#each TABS as [k, l] (k)}
|
||||
<button class:on={tab === k} onclick={() => patch({ tab: k })}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if tab === "albums"}
|
||||
<div class="chips" style="padding-bottom:12px">
|
||||
{#each SORTS as [k, l] (k)}
|
||||
<button class="chip" class:on={sort === k} class:quiet={sort !== k} onclick={() => patch({ sort: k })}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="countline">
|
||||
<span class="t-sub">{list.length} album{list.length === 1 ? "" : "s"} · {totalTracks} tracks</span>
|
||||
<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} />
|
||||
</button>
|
||||
</div>
|
||||
{#if !list.length}
|
||||
<div class="stub"><b>Nothing here yet</b>The library on this phone is empty.</div>
|
||||
{: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 })} />
|
||||
{/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="r"><Icon name="chevron-right" size={17} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
{:else if tab === "artists"}
|
||||
<div class="sec-note">{arts.length} artists · {lib.follows.length} followed</div>
|
||||
{#each arts as ar (ar.name)}
|
||||
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
|
||||
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{ar.name}</span>
|
||||
<span class="m">{ar.albums} album{ar.albums === 1 ? "" : "s"} · {ar.tracks} tracks{isFollowed(ar.name) ? " · following" : ""}</span>
|
||||
</span>
|
||||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if tab === "tracks"}
|
||||
<Tracks {entry} />
|
||||
{:else if tab === "playlists"}
|
||||
{#if !lib.playlists.length}
|
||||
<div class="stub"><b>No playlists on this phone yet.</b>Playlists are part of the library folder, so the ones the desktop keeps come over with it.</div>
|
||||
{/if}
|
||||
{#each lib.playlists as p (p.id)}
|
||||
<button class="row" onclick={() => push("playlist", { id: p.id }, screenEl)}>
|
||||
<span class="art"><Icon name="playlist" size={20} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{p.name}</span>
|
||||
<span class="m">{p.description ? p.description + " · " : ""}{p.track_count} track{p.track_count === 1 ? "" : "s"}</span>
|
||||
</span>
|
||||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if tab === "gaps"}
|
||||
{#if !shelves.length}
|
||||
<div class="stub"><b>Follow the artists you collect.</b>The records they released that this library does not have show up here, shelf by shelf — the desktop's discography lookup, only ever for an artist you followed, so a name is never guessed onto a namesake.</div>
|
||||
{:else}
|
||||
<div class="sec-note">{shelves.length} followed {shelves.length === 1 ? "artist" : "artists"} on this phone</div>
|
||||
{#each shelves as ar (ar.name)}
|
||||
{@const key = foldKey(ar.name)}
|
||||
{@const count = gapCount(ar)}
|
||||
<button class="row" onclick={() => push("artist", { name: ar.name }, screenEl)}>
|
||||
<span class="art round"><Cover path={ar.art || ar.cover} size={20} /></span>
|
||||
<span class="b">
|
||||
<span class="t">{ar.name}</span>
|
||||
<span class="m">{ar.albums} album{ar.albums === 1 ? "" : "s"} here · following</span>
|
||||
</span>
|
||||
<span class="r"><Icon name="chevron-right" size={17} /></span>
|
||||
</button>
|
||||
{#if count !== 0}
|
||||
<button class="gapline" onclick={() => toggleGaps(ar.name)}>
|
||||
{#if gapLoading[key]}Checking the catalogue…{:else if count != null}<b>{count}</b> more released{:else}See what's missing{/if}
|
||||
<span class="cta">{gapOpen[key] ? "hide" : "show"}<Icon name={gapOpen[key] ? "chevron-down" : "chevron-right"} size={12} /></span>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="gapline done"><b>Complete</b> — every released record is here</div>
|
||||
{/if}
|
||||
{#if gapOpen[key] && gapRows[key]?.length}
|
||||
<div class="rail gaps">
|
||||
{#each gapRows[key] as r (r.id)}
|
||||
<Release title={r.title} sub={[yearOf(r.release_date), r.record_type && r.record_type !== "album" ? r.record_type.toUpperCase() : ""].filter(Boolean).join(" · ")} url={r.cover} onclick={() => push("catalogAlbum", { id: r.id, artist: ar.name, title: r.title }, screenEl)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="endpad"></div>
|
||||
Reference in new issue
Block a user