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,211 @@
|
||||
<script>
|
||||
/* Bottom sheets — the phone's answer to the desktop's right-click menus
|
||||
and toolbar popovers. Only rows that do something are here; what a
|
||||
row cannot do yet is not a dead row, it is absent. Facts about a file
|
||||
are key/value rows, not buttons. */
|
||||
import Icon from "../components/Icon.svelte";
|
||||
import Cover from "./Cover.svelte";
|
||||
import * as api from "../lib/api.js";
|
||||
import { nav, closeHalf, push, top, patch, go, back, openHalf, openSheet } from "./nav.svelte.js";
|
||||
import { player, playNext, enqueue, setSleep, sleepLabel } from "./player.svelte.js";
|
||||
import { streamSong, saveSong } from "./resolve.svelte.js";
|
||||
import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js";
|
||||
import { albumById, albumsBy, tracksOf, loadLibrary } from "./lib.svelte.js";
|
||||
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur } from "./util.js";
|
||||
|
||||
const SORTS = [["added", "Recently added"], ["az", "A–Z"], ["artist", "By artist"], ["year", "By year"]];
|
||||
|
||||
/* the last sheet is kept through its slide down */
|
||||
let shown = $state(null);
|
||||
$effect(() => { if (nav.half) shown = nav.half; });
|
||||
|
||||
const kind = $derived.by(() => {
|
||||
const h = shown;
|
||||
if (!h) return null;
|
||||
if (h.kind !== "more") return h.kind;
|
||||
const t = top();
|
||||
if (t.n === "album") return "album-more";
|
||||
if (t.n === "library") return "library-more";
|
||||
return "home-more";
|
||||
});
|
||||
|
||||
/* an album sheet that arrived without its tracks (the page's overflow, a
|
||||
long press on a card) reads them itself */
|
||||
let resolved = $state(null);
|
||||
$effect(() => {
|
||||
const want = kind === "album-more" ? albumById(top().c.id)
|
||||
: kind === "album" && shown?.arg?.album && !shown.arg.tracks && shown.arg.album.id != null ? shown.arg.album
|
||||
: null;
|
||||
if (!want) { resolved = null; return; }
|
||||
let live = true;
|
||||
resolved = { album: want, tracks: [] };
|
||||
tracksOf(want.id).then((t) => { if (live) resolved = { album: want, tracks: t || [] }; }, () => {});
|
||||
return () => { live = false; };
|
||||
});
|
||||
const albumArg = $derived(
|
||||
kind === "album" ? (shown?.arg?.tracks ? shown.arg : resolved)
|
||||
: kind === "album-more" ? resolved
|
||||
: null,
|
||||
);
|
||||
const trackArg = $derived(kind === "track" ? shown?.arg : null);
|
||||
const onThisAlbum = $derived((id) => top().n === "album" && top().c.id === id);
|
||||
const after = $derived(Math.max(0, player.queue.length - player.qi - 1));
|
||||
|
||||
/* a catalog track (no file on this phone): the resolver plays it, Save
|
||||
fetches it — the desktop's row buttons, on the sheet */
|
||||
const isCatalog = (t) => !!t && !t.path && !!t.catalog;
|
||||
let saveNote = $state("");
|
||||
async function saveTrack(track, album) {
|
||||
saveNote = "…";
|
||||
try {
|
||||
const r = await saveSong(track.artist || album?.artist, track.title);
|
||||
saveNote = r ? `Saving from ${r.username}` : "No sources";
|
||||
} catch (e) { saveNote = "Failed"; player.error = String(e); }
|
||||
setTimeout(() => { saveNote = ""; closeHalf(); }, 1400);
|
||||
}
|
||||
function playCatalog(track, album) {
|
||||
closeHalf();
|
||||
streamSong(track.artist || album?.artist, track.title, {
|
||||
trackId: track.catalog?.trackId ?? null, albumId: track.catalog?.albumId ?? album?.catalogId ?? null,
|
||||
album: album?.title || null, cover: track.catalog?.cover ?? album?.art_path ?? null, expectSecs: track.duration_secs ?? null,
|
||||
}).catch((e) => { player.error = String(e); });
|
||||
}
|
||||
|
||||
function act(fn) {
|
||||
return () => { closeHalf(); if (fn) fn(); };
|
||||
}
|
||||
async function rescan() {
|
||||
try { await api.rescanLibrary(); await loadLibrary(); } catch (e) { player.error = String(e); }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet row(icon, t, m, fn)}
|
||||
<button class="row" onclick={fn}>
|
||||
<span class="lead"><Icon name={icon} size={20} /></span>
|
||||
<span class="b"><span class="t">{t}</span>{#if m}<span class="m">{m}</span>{/if}</span>
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
<div class="halfsheet" class:up={!!nav.half}>
|
||||
<div class="grab"></div>
|
||||
<div class="hs-body">
|
||||
{#if kind === "track" && trackArg}
|
||||
{@const album = trackArg.album}
|
||||
{@const track = trackArg.track}
|
||||
<div class="sheet-head">
|
||||
<span class="art">{#if isCatalog(track) && (track.catalog?.cover || album?.art_path)}<img src={track.catalog?.cover || album?.art_path} alt="" />{:else}<Cover path={album?.art_path} eager size={22} />{/if}</span>
|
||||
<span class="b"><span class="t">{track.title}</span><span class="m">{track.artist || album?.artist} · {album?.title}</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{#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); })}
|
||||
<div class="sheet-hair"></div>
|
||||
{#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}
|
||||
{@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 }])))}
|
||||
<div class="sheet-hair"></div>
|
||||
{#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}
|
||||
</div>
|
||||
{#if !isCatalog(track)}
|
||||
<div class="sheet-info">
|
||||
<div class="kv"><span class="k">Format</span><span class="v">{fmtOf(track) || "—"}</span></div>
|
||||
{#if track.size}<div class="kv"><span class="k">Size</span><span class="v">{fmtBytes(track.size)}</span></div>{/if}
|
||||
<div class="kv"><span class="k">File</span><span class="v mono">{basename(track.path)}</span></div>
|
||||
<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(track.path)}</span></div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if (kind === "album" || kind === "album-more") && albumArg}
|
||||
{@const album = albumArg.album}
|
||||
{@const tracks = albumArg.tracks || []}
|
||||
<div class="sheet-head">
|
||||
<span class="art"><Cover path={album.art_path} eager size={22} /></span>
|
||||
<span class="b"><span class="t">{album.title}</span><span class="m">{album.artist} · {album.year ?? "—"} · {tracks.length || album.track_count} tracks</span></span>
|
||||
</div>
|
||||
<div class="sheet-rows">
|
||||
{@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 })))))}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("users", "Go to artist", album.artist, act(() => push("artist", { name: album.artist })))}
|
||||
</div>
|
||||
<div class="sheet-info">
|
||||
<div class="kv"><span class="k">Format</span><span class="v">{albumFmt(tracks) || "—"}</span></div>
|
||||
<div class="kv"><span class="k">Length</span><span class="v">{longDur(tracks.reduce((n, t) => n + (t.duration_secs || 0), 0))}</span></div>
|
||||
{#if tracks[0]?.path}<div class="kv"><span class="k">Folder</span><span class="v mono dim">{dirname(tracks[0].path)}</span></div>{/if}
|
||||
</div>
|
||||
{:else if kind === "librarysort"}
|
||||
<div class="sheet-title">Sort & view</div>
|
||||
<div class="sheet-sec">Order</div>
|
||||
<div class="chips" style="padding-bottom:12px">
|
||||
{#each SORTS as [k, l] (k)}
|
||||
<button class="chip" class:on={(top().c.sort || "added") === k} class:quiet={(top().c.sort || "added") !== k} onclick={() => patch({ sort: k, tab: "albums" })}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sheet-sec">View</div>
|
||||
<div class="chips" style="padding-bottom:12px">
|
||||
{#each [["grid", "Grid"], ["list", "List"]] as [k, l] (k)}
|
||||
<button class="chip" class:on={(top().c.view || "grid") === k} class:quiet={(top().c.view || "grid") !== k} onclick={() => patch({ view: k, tab: "albums" })}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div style="padding: 4px var(--pad) 16px"><button class="btn btn-fill wide" onclick={closeHalf}>Done</button></div>
|
||||
{:else if kind === "library-more"}
|
||||
<div class="sheet-title">Library</div>
|
||||
<div class="sheet-rows">
|
||||
{@render row("layers", "Sort and view", "", () => openHalf("librarysort"))}
|
||||
{@render row("playlist", "Playlists", "", act(() => patch({ tab: "playlists" })))}
|
||||
<div class="sheet-hair"></div>
|
||||
{@render row("refresh", "Rescan library", "looks for new files in the folder", act(rescan))}
|
||||
</div>
|
||||
{:else if kind === "home-more"}
|
||||
<div class="sheet-title">SoulStream</div>
|
||||
<div class="sheet-rows">
|
||||
{#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")))}
|
||||
</div>
|
||||
{:else if kind === "community"}
|
||||
<div class="sheet-title">Community</div>
|
||||
<div class="sheet-rows">
|
||||
{@render row("refresh", "Refresh the rooms", slsk.rooms.length + " on the server", act(refreshRooms))}
|
||||
{@render row("x", "Sign out", slsk.username, act(disconnect))}
|
||||
</div>
|
||||
<div class="sec-note" style="padding-bottom:16px">Spaces, holdings and the sealed inbox wait for the private layer on the phone.</div>
|
||||
{:else if kind === "roomusers" && shown?.arg?.name}
|
||||
{@const room = slsk.joined[shown.arg.name]}
|
||||
<div class="sheet-title">#{shown.arg.name} <span class="t-sub">· {room?.users.length ?? 0} here</span></div>
|
||||
<div class="sheet-rows">
|
||||
{#each (room?.users ?? []).slice(0, 120) as u (u.username)}
|
||||
{@const buddy = slsk.buddies.includes(u.username)}
|
||||
<button class="row" onclick={act(() => { openThread(u.username); push("thread", { username: u.username }); })} oncontextmenu={(e) => { e.preventDefault(); (buddy ? removeBuddy : addBuddy)(u.username).catch(() => {}); }}>
|
||||
<span class="lead"><span class="dot" class:off={u.status === 0} class:warn={u.status === 1}></span></span>
|
||||
<span class="b"><span class="t">{u.username}{#if buddy} <span class="tag hi">BUDDY</span>{/if}</span><span class="m">{u.file_count ? u.file_count.toLocaleString() + " files" : ""}</span></span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div style="padding: 6px var(--pad) 16px"><button class="btn btn-ghost wide" onclick={act(() => { leaveRoom(shown.arg.name).catch(() => {}); back(); })}>Leave #{shown.arg.name}</button></div>
|
||||
{:else if kind === "sleep"}
|
||||
<div class="sheet-title">Sleep timer</div>
|
||||
<div class="sheet-sec">Pause after</div>
|
||||
<div class="chips" style="padding-bottom:16px">
|
||||
{#each [[0, "Off"], [15, "15 min"], [30, "30 min"], [45, "45 min"], [60, "1 hour"], [90, "1½ hours"]] as [m, l] (m)}
|
||||
<button class="chip" class:on={m === 0 ? !player.sleepAt : Math.abs((player.sleepAt - Date.now()) / 60000 - m) < 1} class:quiet={!(m === 0 ? !player.sleepAt : Math.abs((player.sleepAt - Date.now()) / 60000 - m) < 1)} onclick={act(() => setSleep(m))}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{#if player.sleepAt}<div class="sec-note" style="padding-bottom:14px">Playback pauses in {sleepLabel().replace(" left", "")}.</div>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in new issue
Block a user