android: downloads on Wi-Fi only

One switch the desktop never needed, off by default: on mobile data every
Save the phone can start — a record, a track, a folder, a file, a
playlist's bulk, the now-playing Save — waits, with the reason on the bar;
streaming is never held back. The walk flips the switch, makes the
connection cellular and sees a download refused.
This commit is contained in:
KeelrLeek committed 2026-09-13 22:15:07 -05:00
1 parent 3c8989dfa6
commit 694a29eb0d
10 files changed
+55 -5

No files matched your search

+5 -1
View File
@@ -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 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 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, 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. 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 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 offers By format (lossless by depth and rate first, then by bitrate); the
+4 -1
View File
@@ -18,6 +18,7 @@
import { lib, albumById, albumsBy, tracksOf, loadLibrary, refreshPlaylists, refreshRecents } from "./lib.svelte.js"; 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 { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics } from "./lyrics.svelte.js";
import { art, wantArt } from "./art.svelte.js"; import { art, wantArt } from "./art.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
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";
@@ -185,6 +186,7 @@
pipeline — and the whole streamed record the queue holds */ 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); 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) { 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 })); 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; if (!srcs.length) return;
try { try {
@@ -200,6 +202,7 @@
if (es.length) playEntries(es, 0, g.folderName).catch(oops); if (es.length) playEntries(es, 0, g.folderName).catch(oops);
} }
async function saveGroup(g) { async function saveGroup(g) {
if (!downloadsAllowed()) return;
try { try {
for (const f of g.files) await api.downloadFile(g.username, f.path, f.size); 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}`); note(`Saving ${g.files.length} file${g.files.length === 1 ? "" : "s"} from ${g.username}`);
@@ -544,7 +547,7 @@
<div class="sheet-rows"> <div class="sheet-rows">
{@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); }))} {@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} {#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} {/if}
<div class="sheet-hair"></div> <div class="sheet-hair"></div>
{@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))} {@render row("folder-open", "Browse their files", "everything " + g.username + " shares", act(() => push("browse", { username: g.username })))}
+2 -1
View File
@@ -47,6 +47,7 @@
import * as pls from "./playlists.svelte.js"; import * as pls from "./playlists.svelte.js";
import { importer, bindImporter } from "./importer.svelte.js"; import { importer, bindImporter } from "./importer.svelte.js";
import { bindShare } from "./share.svelte.js"; import { bindShare } from "./share.svelte.js";
import { downloadsAllowed, cellular } from "./network.svelte.js";
import * as sources from "../lib/sources.js"; import * as sources from "../lib/sources.js";
import { greeting, drag } from "./util.js"; import { greeting, drag } from "./util.js";
@@ -191,7 +192,7 @@
setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000); setTimeout(() => { api.libraryArtPrewarm([360]).catch(() => {}); }, 8000);
/* the probe handle — the desktop has __soulstream for the same reason: /* the probe handle — the desktop has __soulstream for the same reason:
tests/mobile/smoke.mjs drives the real app through it */ 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(); }; return () => { if (unlisten) unlisten(); };
}); });
</script> </script>
+22
View File
@@ -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;
}
+2
View File
@@ -12,6 +12,7 @@ import { player, playEntries } from "./player.svelte.js";
import { streamSong } from "./resolve.svelte.js"; import { streamSong } from "./resolve.svelte.js";
import { resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, normText } from "../lib/sources.js"; import { resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, normText } from "../lib/sources.js";
import { slsk } from "./slsk.svelte.js"; import { slsk } from "./slsk.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
import { note } from "./notice.svelte.js"; import { note } from "./notice.svelte.js";
export const pls = $state({ 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 const bulkable = (items) => items.filter((i) => i.kind === "stream" && i.artist && i.title).length;
export async function runBulk(p, items, mode) { export async function runBulk(p, items, mode) {
if (bulk.active) return false; if (bulk.active) return false;
if (mode === "download" && !downloadsAllowed()) return false;
const seen = new Set(); const seen = new Set();
const want = items.filter((r) => { const want = items.filter((r) => {
if (r.kind !== "stream" || !r.artist || !r.title) return false; if (r.kind !== "stream" || !r.artist || !r.title) return false;
+1 -1
View File
@@ -4,7 +4,7 @@
/* `glass` is the desktop's frosted chrome (a backdrop blur behind the bars). /* `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 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. */ 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"; const KEY = "ss.mobile.prefs";
+3
View File
@@ -20,6 +20,7 @@ import { player, hooks, playEntries, playAlbum } from "./player.svelte.js";
import { lib, tracksOf } from "./lib.svelte.js"; import { lib, tracksOf } from "./lib.svelte.js";
import { slsk } from "./slsk.svelte.js"; import { slsk } from "./slsk.svelte.js";
import { yearOf } from "./catalog.svelte.js"; import { yearOf } from "./catalog.svelte.js";
import { downloadsAllowed } from "./network.svelte.js";
export const resolver = $state({ active: false, label: "", error: "" }); 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. * art and logs beside the finished album and says soulseek:library-changed.
* Returns { username, files } or false. */ * Returns { username, files } or false. */
export async function saveCatalogAlbum(album) { export async function saveCatalogAlbum(album) {
if (!downloadsAllowed()) return false;
if (resolver.active) return false; if (resolver.active) return false;
if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; } if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; }
const catalogTracks = album.id const catalogTracks = album.id
@@ -218,6 +220,7 @@ export async function saveCatalogAlbum(album) {
/** One song into the library, by the download rules. */ /** One song into the library, by the download rules. */
export async function saveSong(artist, title) { export async function saveSong(artist, title) {
if (resolver.active) return false; if (resolver.active) return false;
if (!downloadsAllowed()) return false;
if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; } if (!online()) { resolver.error = OFFLINE; player.error = OFFLINE; return false; }
const { responses, artistIncluded } = await resolveSources( const { responses, artistIncluded } = await resolveSources(
`${artist} — ${title}`, `${artist} — ${title}`,
+2 -1
View File
@@ -10,6 +10,7 @@
import { player, playEntries } from "../player.svelte.js"; import { player, playEntries } from "../player.svelte.js";
import { openHalf } from "../nav.svelte.js"; import { openHalf } from "../nav.svelte.js";
import { note } from "../notice.svelte.js"; import { note } from "../notice.svelte.js";
import { downloadsAllowed } from "../network.svelte.js";
import { mmss, fmtBytes, titleFromName, folderMeta } from "../util.js"; import { mmss, fmtBytes, titleFromName, folderMeta } from "../util.js";
let { username } = $props(); let { username } = $props();
@@ -76,7 +77,7 @@
} }
async function save(d) { async function save(d) {
const list = d.files.filter(audio); const list = d.files.filter(audio);
if (!list.length) return; if (!list.length || !downloadsAllowed()) return;
try { try {
for (const f of list) await api.downloadFile(username, f.path, f.size); for (const f of list) await api.downloadFile(username, f.path, f.size);
note(`Saving ${list.length} file${list.length === 1 ? "" : "s"} from ${username}`); note(`Saving ${list.length} file${list.length === 1 ? "" : "s"} from ${username}`);
+5
View File
@@ -242,6 +242,11 @@
<div class="sheet-sec">Downloads</div> <div class="sheet-sec">Downloads</div>
<div class="group"> <div class="group">
<button class="row" onclick={() => setSetting("wifiOnly", !settings.wifiOnly)}>
<span class="lead"><Icon name="globe" size={20} /></span>
<span class="b"><span class="t">Downloads on Wi-Fi only</span><span class="m">on mobile data, Save waits; streaming is never held back</span></span>
<span class="sw" class:on={settings.wifiOnly}><span></span></span>
</button>
<button class="row" onclick={layoutPrompt}> <button class="row" onclick={layoutPrompt}>
<span class="lead"><Icon name="folder-open" size={20} /></span> <span class="lead"><Icon name="folder-open" size={20} /></span>
<span class="b"><span class="t">Album folder format</span><span class="m mono">{cfg?.library_layout || "{artist}\\{album}"}</span></span> <span class="b"><span class="t">Album folder format</span><span class="m mono">{cfg?.library_layout || "{artist}\\{album}"}</span></span>
+9
View File
@@ -117,6 +117,7 @@ const PROBE = `(function () {
jump: document.querySelectorAll('.screen .rail .release').length, jump: document.querySelectorAll('.screen .rail .release').length,
countline: (document.querySelector('.countline .t-sub') || {}).textContent || '', countline: (document.querySelector('.countline .t-sub') || {}).textContent || '',
navBadges: [].map.call(document.querySelectorAll('.nav .nb'), function (b) { return b.textContent.trim(); }).join('|'), navBadges: [].map.call(document.querySelectorAll('.nav .nb'), function (b) { return b.textContent.trim(); }).join('|'),
wifiOnly: (function () { var r = [].slice.call(document.querySelectorAll('.screen .row')).find(function (x) { return /Wi-Fi only/.test(x.textContent); }); return r ? (r.querySelector('.sw.on') ? 'on' : 'off') : ''; })(),
firstBadge: (document.querySelector('.grid2 .release .ed-badge') || {}).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,
@@ -383,6 +384,11 @@ const MOCK_STEPS = [
{ do: `__click('.screen .row', 'Word-by-word')`, wait: 400 }, { do: `__click('.screen .row', 'Word-by-word')`, wait: 400 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 500 }, { do: `__click('.prefs[data-mode="albumart"] .chip', '1000 px')`, name: "you-artedge", wait: 500 },
{ do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 }, { do: `__click('.prefs[data-mode="albumart"] .chip', '1500 px')`, wait: 400 },
/* downloads on Wi-Fi only: the switch on, the connection made cellular, a download refused; then off */
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-on", wait: 400 },
{ do: `(function () { try { Object.defineProperty(navigator, 'connection', { value: { type: 'cellular' }, configurable: true }); } catch (e) {} return __ss.wifi.downloadsAllowed(); })()`, name: "wifi-refused", wait: 400 },
{ do: `__click('.toast:not(.notice)', null)`, wait: 300 },
{ do: `__click('.screen .row', 'Wi-Fi only')`, name: "wifi-off", wait: 400 },
{ do: `__click('.screen .row', 'Clear play history')`, name: "history-confirm", wait: 600 }, { do: `__click('.screen .row', 'Clear play history')`, name: "history-confirm", wait: 600 },
{ do: `__click('.halfsheet .prompt-acts .btn-fill', 'Clear')`, name: "history-cleared", wait: 800 }, { do: `__click('.halfsheet .prompt-acts .btn-fill', 'Clear')`, name: "history-cleared", wait: 800 },
/* recent searches on the empty page: the ones this walk used */ /* recent searches on the empty page: the ones this walk used */
@@ -413,6 +419,9 @@ const MOCK_EXPECT = {
"you-slots": (i) => i.slots === "3", "you-slots": (i) => i.slots === "3",
"you-words": (i) => i.wordfill === "on", "you-words": (i) => i.wordfill === "on",
"you-artedge": (i) => i.artEdge === "1000 px", "you-artedge": (i) => i.artEdge === "1000 px",
"wifi-on": (i) => i.wifiOnly === "on",
"wifi-refused": (i) => /Wi-Fi/.test(i.toast),
"wifi-off": (i) => i.wifiOnly === "off" && !i.toast,
"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 && i.jump >= 1, "search-recent": (i) => i.title.startsWith("Search") && i.recents >= 1 && i.jump >= 1,