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:
KeelrLeek committed 2026-09-13 09:30:06 -05:00
1 parent 972dd103cb
commit bb828ff2dc
102 files changed
+12258 -830

No files matched your search

+275
View File
@@ -0,0 +1,275 @@
<script>
/* The player sheet. Three FACES — art, lyrics, queue — of one screen
rather than three destinations, because on a phone the thing you are
listening to should never be somewhere you navigate away to.
Two arrivals. Tapping the mini bar is a container transform: the
artwork you tapped IS the big artwork, so it flies there and the sheet
materialises around it (FLIP on the spatial spring). Anything else
slides up. Drag the head or the art down to put it away; swipe them
sideways to skip; drag the lane to seek. */
import { tick } from "svelte";
import Icon from "../components/Icon.svelte";
import Lane from "./Lane.svelte";
import Cover from "./Cover.svelte";
import * as api from "../lib/api.js";
import { art } from "./art.svelte.js";
import { albumsBy } from "./lib.svelte.js";
import { player, next, prev, toggle, seekFrac, seekSecs, playQueueAt, toggleShuffle, cycleRepeat } from "./player.svelte.js";
import { nav, closeSheet, morphSource, openHalf, push } from "./nav.svelte.js";
import { mmss, drag } from "./util.js";
let frameEl = $state(null);
let faceEl = $state(null);
let shown = $state(false);
let morphing = $state(false);
let morphTimer = null;
const cover = $derived(player.album?.art_path ? art[player.album.art_path] : "");
const face = $derived(nav.face);
const small = $derived(face !== "art");
const nextEntry = $derived(player.queue[player.qi + 1] ?? null);
const crossing = $derived(!!nextEntry && nextEntry.album?.title !== player.album?.title);
const played = $derived(player.duration ? Math.min(1, player.position / player.duration) : 0);
const remaining = $derived(Math.max(0, (player.duration ?? 0) - player.position));
const playing = $derived(player.status === "playing" || player.status === "buffering");
const artistName = $derived(player.track?.artist || player.album?.artist || "");
/* the artist page stands on the library and fills in from the catalog,
so any name opens it */
const artistInLibrary = $derived(!!artistName);
/* ── arrival ─────────────────────────────────────────────────────── */
$effect(() => {
if (!nav.sheet) {
shown = false;
morphing = false;
clearTimeout(morphTimer);
return;
}
if (nav.morph && morphSource.rect) {
morphing = true;
shown = false;
tick().then(() => requestAnimationFrame(() => {
shown = true;
const dur = flip(morphSource.rect);
clearTimeout(morphTimer);
/* once the art has landed the sheet is an ordinary sheet
again: drags spring back and a close slides down */
morphTimer = setTimeout(() => { morphing = false; }, dur + 40);
}));
} else {
morphing = false;
shown = true;
}
});
function flip(a) {
const to = frameEl;
const css = getComputedStyle(document.documentElement);
const dur = parseFloat(css.getPropertyValue("--spd-spatial-default")) || 480;
if (!a || !to || typeof to.animate !== "function") return 0;
const b = to.getBoundingClientRect();
if (!a.width || !b.width) return 0;
const s = a.width / b.width;
const dx = a.left + a.width / 2 - (b.left + b.width / 2);
const dy = a.top + a.height / 2 - (b.top + b.height / 2);
const ease = css.getPropertyValue("--sp-spatial-default").trim() || "ease-out";
to.animate(
[{ transform: `translate(${dx}px,${dy}px) scale(${s})` }, { transform: "none" }],
{ duration: dur, easing: ease, fill: "both" },
);
return dur;
}
const sheetDrag = {
from: (t) => !!(t.closest(".np-head") || t.closest(".np-art")),
move: (dx, dy, el) => { if (dy > 0) el.style.transform = `translateY(${dy}px)`; },
end: (dx, dy, el) => {
el.style.transform = "";
if (dy > 110) closeSheet();
else if (Math.abs(dx) > 70 && Math.abs(dx) > Math.abs(dy)) { if (dx < 0) next(); else prev(); }
},
};
function goArtist() {
if (!artistInLibrary) return;
closeSheet();
push("artist", { name: artistName });
}
function goAlbum() {
const a = player.album;
if (!a) return;
if (a.id != null) { closeSheet(); push("album", { id: a.id }); return; }
if (a.catalogId != null) { closeSheet(); push("catalogAlbum", { id: a.catalogId, artist: a.artist, title: a.title }); }
}
function more() {
if (player.track) openHalf("track", { album: player.album, track: player.track });
}
/* ── lyrics: the app's own pipeline, per track ───────────────────── */
let lyr = $state({ key: null, status: "none", lines: [], plain: "", offset: 0 });
$effect(() => {
if (face !== "lyrics" || !player.track) return;
const t = player.track;
const key = t.path;
if (lyr.key === key) return;
lyr = { key, status: "loading", lines: [], plain: "", offset: 0 };
api.lyricsGet({
title: t.title,
artist: t.artist || player.album?.artist || "",
album: player.album?.title ?? null,
durationSecs: t.duration_secs ?? null,
path: t.path,
}).then(
(p) => {
if (lyr.key !== key) return;
lyr = {
key,
status: p?.status || "none",
lines: p?.synced || [],
plain: p?.plain || "",
offset: (p?.offset_ms || 0) + (p?.auto_offset_ms || 0),
};
},
() => { if (lyr.key === key) lyr = { key, status: "none", lines: [], plain: "", offset: 0 }; },
);
});
const nowMs = $derived(player.position * 1000);
const activeIdx = $derived.by(() => {
let i = -1;
for (let k = 0; k < lyr.lines.length; k++) {
if (lyr.lines[k].time_ms + lyr.offset <= nowMs) i = k;
else break;
}
return i;
});
/* the active line stays in view */
$effect(() => {
const i = activeIdx;
const host = faceEl;
if (i < 0 || !host || face !== "lyrics") return;
const el = host.querySelector(`[data-ln="${i}"]`);
if (el && typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", behavior: "smooth" });
});
function lineSeek(l) { seekSecs(Math.max(0, (l.time_ms + lyr.offset) / 1000)); }
</script>
<div class="sheet" class:up={shown} class:morphing use:drag={sheetDrag}>
{#if player.album}
<div class="sheet-bg" aria-hidden="true">
<div class="amb on" style:background-image={cover ? `url("${cover}")` : null}></div>
<div class="amb-tint"></div>
<div class="grain"></div>
</div>
<div class="np">
<div class="np-head">
<button class="iconbtn" onclick={closeSheet} aria-label="Close"><Icon name="chevron-down" size={24} /></button>
<button class="np-ctx" onclick={goAlbum} aria-label="Go to the record">
<div class="k">{player.track?.stream ? "Streaming from " + player.track.stream.username : "Playing from " + (player.from && player.from !== player.album.title ? "playlist" : "album")}</div>
<div class="v">{player.from || player.album.title}</div>
</button>
<button class="iconbtn" onclick={more} aria-label="More"><Icon name="more" size={22} /></button>
</div>
<div class="np-art" class:small>
<div class="np-frame" bind:this={frameEl}><Cover path={player.album.art_path} eager size={64} /></div>
</div>
{#if !small}
<div class="np-spacer">
{#if nextEntry}
<button class="np-next" onclick={() => playQueueAt(player.qi + 1)}>
<Icon name="skip-forward" size={13} />
<span><b>Next</b> {nextEntry.t.title}{#if crossing}<i> · {nextEntry.album?.artist}</i>{/if}</span>
<Icon name="chevron-right" size={14} />
</button>
{/if}
</div>
{:else}
<div class="np-face" bind:this={faceEl}>
{#if face === "lyrics"}
<div class="lyr">
{#if lyr.status === "loading"}
<div class="lyr-note">Looking for the words…</div>
{:else if lyr.lines.length}
{#each lyr.lines as l, i (i)}
{#if !l.text.trim()}
<div class="lyr-gap"></div>
{:else}
<button class="ln" class:past={i < activeIdx} class:now={i === activeIdx} class:words={i === activeIdx && l.words?.length} data-ln={i} onclick={() => lineSeek(l)}>
{#if i === activeIdx && l.words?.length}
{#each l.words as w, k (k)}<span class:fill={w.time_ms + lyr.offset <= nowMs}>{w.text}</span>{k < l.words.length - 1 ? " " : ""}{/each}
{:else}
{l.text}
{/if}
</button>
{/if}
{/each}
{:else if lyr.plain}
<div class="lyr-plain">{lyr.plain}</div>
<div class="lyr-note">Plain lyrics — nothing timed for this recording.</div>
{:else if lyr.status === "instrumental"}
<div class="lyr-note">Instrumental.</div>
{:else}
<div class="lyr-note">No lyrics found for this track.</div>
{/if}
</div>
{:else}
<div class="q-face">
<div class="sec" style="padding-top:8px">
<h2>Up next <em>· from {player.from || player.album.title}</em></h2>
<button class="all" class:on={player.shuffle} onclick={toggleShuffle} aria-label="Shuffle"><Icon name="shuffle" size={14} /></button>
</div>
{#each player.queue.slice(player.qi + 1, player.qi + 41) as e, k (player.qi + 1 + k)}
{@const prevAlbum = k === 0 ? player.album?.title : player.queue[player.qi + k]?.album?.title}
{#if e.album?.title !== prevAlbum}
<div class="q-break">{e.album?.artist} · {e.album?.title}</div>
{/if}
<button class="row" onclick={() => playQueueAt(player.qi + 1 + k)} oncontextmenu={(ev) => { ev.preventDefault(); openHalf("track", { album: e.album, track: e.t }); }}>
<span class="art"><Cover path={e.album?.art_path} size={18} /></span>
<span class="b"><span class="t">{e.t.title}</span><span class="m">{e.t.artist || e.album?.artist}</span></span>
<span class="r">{mmss(e.t.duration_secs)}</span>
</button>
{/each}
{#if player.qi + 1 >= player.queue.length}
<div class="stub"><b>End of the queue</b>Nothing after this.</div>
{/if}
</div>
{/if}
</div>
{/if}
<div class="np-meta">
<div class="b">
<div class="np-t">{player.track?.title}</div>
{#if artistInLibrary}
<button class="np-a" onclick={goArtist}>{artistName}</button>
{:else}
<span class="np-a">{artistName}</span>
{/if}
</div>
</div>
<div class="np-scrub">
<Lane height={38} peaks={player.peaks} heldPeaks={player.heldPeaks} phase={player.phase} coverage={player.coverage} played={played} onseek={seekFrac} />
<div class="times"><span>{mmss(player.position)}</span><span>-{mmss(remaining)}</span></div>
</div>
<div class="np-transport">
<button class="iconbtn" class:on={player.shuffle} class:muted={!player.shuffle} onclick={toggleShuffle} aria-label="Shuffle"><Icon name="shuffle" size={21} /></button>
<button class="iconbtn" onclick={prev} aria-label="Previous"><Icon name="skip-back" size={30} /></button>
<button class="disc lg" onclick={toggle} aria-label={playing ? "Pause" : "Play"}><Icon name={playing ? "pause" : "play"} size={27} /></button>
<button class="iconbtn" onclick={next} aria-label="Next"><Icon name="skip-forward" size={30} /></button>
<button class="iconbtn" class:on={player.repeat > 0} class:muted={!player.repeat} onclick={cycleRepeat} aria-label="Repeat"><Icon name={player.repeat === 2 ? "repeat-one" : "repeat"} size={21} /></button>
</div>
<div class="np-foot">
<div class="np-faces">
{#each [["art", "disc", "Art"], ["lyrics", "lyrics", "Lyrics"], ["queue", "playlist", "Queue"]] as [f, ic, lb] (f)}
<button class="fbtn" class:on={face === f} onclick={() => (nav.face = f)}><Icon name={ic} size={17} /><span>{lb}</span></button>
{/each}
</div>
</div>
</div>
{/if}
</div>