diff --git a/ANDROID.md b/ANDROID.md
index eb153a9..4571f92 100644
--- a/ANDROID.md
+++ b/ANDROID.md
@@ -215,7 +215,11 @@ record the queue holds. A moment after boot the phone warms the library's thumbn
desktop's way (`library_art_prewarm` at the cards' edge, one decode at a
time behind the tiles' gate). The network results carry the hub's filters (a free slot only; FLAC or
MP3 320+), and the lyrics sheet has the desktop's paste door (plain text,
-or LRC with timestamps, kept for the track). The nav wears two badges the desktop's chrome has: transfers in flight on
+or LRC with timestamps, kept for the track). One switch the desktop never needed: You → Downloads → "Downloads on
+Wi-Fi only" (`network.svelte.js`, off by default) holds every Save — a
+record, a track, a folder, a file, a playlist's bulk, the now-playing
+Save — while the phone is on mobile data (`navigator.connection.type`);
+streaming is never held back. The nav wears two badges the desktop's chrome has: transfers in flight on
You (the title bar's download ring) and unread chats on Community.
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
diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte
index 81a4a01..3c27465 100644
--- a/src/mobile/HalfSheet.svelte
+++ b/src/mobile/HalfSheet.svelte
@@ -18,6 +18,7 @@
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js";
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";
import { art, wantArt } from "./art.svelte.js";
+ import { downloadsAllowed } from "./network.svelte.js";
import { note } from "./notice.svelte.js";
import { fmtOf, albumFmt, fmtBytes, basename, dirname, longDur, mmss, titleFromName } from "./util.js";
@@ -185,6 +186,7 @@
pipeline — and the whole streamed record the queue holds */
const streamMates = (track, album) => player.queue.filter((e) => e.t?.stream && e.t.path && e.album?.title === album?.title && e.album?.artist === album?.artist);
async function saveStreams(entries) {
+ if (!downloadsAllowed()) return;
const srcs = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path, size: e.t.stream.size }));
if (!srcs.length) return;
try {
@@ -200,6 +202,7 @@
if (es.length) playEntries(es, 0, g.folderName).catch(oops);
}
async function saveGroup(g) {
+ if (!downloadsAllowed()) return;
try {
for (const f of g.files) await api.downloadFile(g.username, f.path, f.size);
note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
@@ -544,7 +547,7 @@
{@render row("play", "Play from here", "the rest of the folder follows", act(() => { const es = groupEntries(g, i); if (es.length) playEntries(es, 0, g.folderName).catch(oops); }))}
{#if f}
- {@render row("download", "Save this file to library", "as it is, from " + g.username, act(() => api.downloadFile(g.username, f.path, f.size).then(() => note(`Saving “${titleFromName(f.name)}” from ${g.username}`)).catch(oops)))}
+ {@render row("download", "Save this file to library", "as it is, from " + g.username, act(() => { if (!downloadsAllowed()) return; api.downloadFile(g.username, f.path, f.size).then(() => note(`Saving “${titleFromName(f.name)}” from ${g.username}`)).catch(oops); }))}
{/if}
{@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))}
diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte
index c30d497..5ff1dfc 100644
--- a/src/mobile/Mobile.svelte
+++ b/src/mobile/Mobile.svelte
@@ -47,6 +47,7 @@
import * as pls from "./playlists.svelte.js";
import { importer, bindImporter } from "./importer.svelte.js";
import { bindShare } from "./share.svelte.js";
+ import { downloadsAllowed, cellular } from "./network.svelte.js";
import * as sources from "../lib/sources.js";
import { greeting, drag } from "./util.js";
@@ -191,7 +192,7 @@
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 };
+ 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, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch };
return () => { if (unlisten) unlisten(); };
});
diff --git a/src/mobile/network.svelte.js b/src/mobile/network.svelte.js
new file mode 100644
index 0000000..b0ced29
--- /dev/null
+++ b/src/mobile/network.svelte.js
@@ -0,0 +1,22 @@
+/* The phone's connection — for the one guard the desktop never needed:
+ downloads kept to Wi-Fi when the switch says so (You → Downloads).
+ Streaming is never refused: that is what a phone on the network is for.
+ navigator.connection is Chrome's; a WebView without it reports nothing,
+ and nothing is refused. */
+import { settings } from "./prefs.svelte.js";
+import { player } from "./player.svelte.js";
+
+export const cellular = () => {
+ const c = typeof navigator !== "undefined" ? navigator.connection : null;
+ return !!c && c.type === "cellular";
+};
+export const NO_WIFI = "Downloads wait for Wi-Fi — the switch is under You → Downloads.";
+
+/** true when a download may start now; otherwise the reason is on the bar */
+export function downloadsAllowed() {
+ if (settings.wifiOnly && cellular()) {
+ player.error = NO_WIFI;
+ return false;
+ }
+ return true;
+}
diff --git a/src/mobile/playlists.svelte.js b/src/mobile/playlists.svelte.js
index 5200ace..3419310 100644
--- a/src/mobile/playlists.svelte.js
+++ b/src/mobile/playlists.svelte.js
@@ -12,6 +12,7 @@ import { player, playEntries } from "./player.svelte.js";
import { streamSong } from "./resolve.svelte.js";
import { resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, normText } from "../lib/sources.js";
import { slsk } from "./slsk.svelte.js";
+import { downloadsAllowed } from "./network.svelte.js";
import { note } from "./notice.svelte.js";
export const pls = $state({
@@ -306,6 +307,7 @@ export function dismissBulk() { bulk.summary = null; }
export const bulkable = (items) => items.filter((i) => i.kind === "stream" && i.artist && i.title).length;
export async function runBulk(p, items, mode) {
if (bulk.active) return false;
+ if (mode === "download" && !downloadsAllowed()) return false;
const seen = new Set();
const want = items.filter((r) => {
if (r.kind !== "stream" || !r.artist || !r.title) return false;
diff --git a/src/mobile/prefs.svelte.js b/src/mobile/prefs.svelte.js
index 2910e8e..ac32138 100644
--- a/src/mobile/prefs.svelte.js
+++ b/src/mobile/prefs.svelte.js
@@ -4,7 +4,7 @@
/* `glass` is the desktop's frosted chrome (a backdrop blur behind the bars).
Off by default on the phone: tests/mobile/perf.mjs measured it at half the
frames on a Home scroll and on the player's opening. */
-export const settings = $state({ ambience: true, accent: true, motion: true, glass: false });
+export const settings = $state({ ambience: true, accent: true, motion: true, glass: false, wifiOnly: false });
const KEY = "ss.mobile.prefs";
diff --git a/src/mobile/resolve.svelte.js b/src/mobile/resolve.svelte.js
index 4f09546..af70e7d 100644
--- a/src/mobile/resolve.svelte.js
+++ b/src/mobile/resolve.svelte.js
@@ -20,6 +20,7 @@ import { player, hooks, playEntries, playAlbum } from "./player.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js";
import { slsk } from "./slsk.svelte.js";
import { yearOf } from "./catalog.svelte.js";
+import { downloadsAllowed } from "./network.svelte.js";
export const resolver = $state({ active: false, label: "", error: "" });
@@ -189,6 +190,7 @@ export async function streamCatalogAlbum(album, at = 0) {
* art and logs beside the finished album and says soulseek:library-changed.
* Returns { username, files } or false. */
export async function saveCatalogAlbum(album) {
+ if (!downloadsAllowed()) return false;
if (resolver.active) return false;
if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; }
const catalogTracks = album.id
@@ -218,6 +220,7 @@ export async function saveCatalogAlbum(album) {
/** One song into the library, by the download rules. */
export async function saveSong(artist, title) {
if (resolver.active) return false;
+ if (!downloadsAllowed()) return false;
if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; }
const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`,
diff --git a/src/mobile/screens/Browse.svelte b/src/mobile/screens/Browse.svelte
index d52178d..d616b35 100644
--- a/src/mobile/screens/Browse.svelte
+++ b/src/mobile/screens/Browse.svelte
@@ -10,6 +10,7 @@
import { player, playEntries } from "../player.svelte.js";
import { openHalf } from "../nav.svelte.js";
import { note } from "../notice.svelte.js";
+ import { downloadsAllowed } from "../network.svelte.js";
import { mmss, fmtBytes, titleFromName, folderMeta } from "../util.js";
let { username } = $props();
@@ -76,7 +77,7 @@
}
async function save(d) {
const list = d.files.filter(audio);
- if (!list.length) return;
+ if (!list.length || !downloadsAllowed()) return;
try {
for (const f of list) await api.downloadFile(username, f.path, f.size);
note(`Saving ${list.length} file${list.length === 1 ? "" : "s"} from ${username}`);
diff --git a/src/mobile/screens/You.svelte b/src/mobile/screens/You.svelte
index a0a2f33..19c63b8 100644
--- a/src/mobile/screens/You.svelte
+++ b/src/mobile/screens/You.svelte
@@ -242,6 +242,11 @@