diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index a5f686e..ca024be 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -14,6 +14,7 @@ import { streamSong, saveSong, streamCatalogAlbum, saveCatalogAlbum } from "./resolve.svelte.js"; import { catalog, yearOf, artistKey, picture, heroFromPhoto, heroClear } from "./catalog.svelte.js"; import { pls, bulk, bulkable, runBulk, coverFor, queueable, entryOf, playFrom, shufflePlay, togglePin, renamePlaylist, describePlaylist, duplicatePlaylist, deletePlaylist, removeItems, addEntries, createPlaylist, moveItem, coverFromPhoto, coverClear, EXPORTS, exportPlaylist } from "./playlists.svelte.js"; + import { isOffline, setOffline } from "./offline.svelte.js"; import { DOORS, start as startImport, pickImportFolder, startFolder, pickImportFile, startFile } from "./importer.svelte.js"; import { slsk, refreshRooms, disconnect, openThread, addBuddy, removeBuddy, leaveRoom } from "./slsk.svelte.js"; import { lib, albumById, tracksOf, loadLibrary, refreshPlaylists } from "./lib.svelte.js"; @@ -482,6 +483,14 @@ {#if kind === "playlist-more" && unres.length} {@render row("x", "Remove unresolved entries", unres.length + " that never matched a track", act(() => removeItems(p, rows, unres).catch(oops)))} {/if} + {#if kind === "playlist-more" && bulkable(rows)} +
+ {#if isOffline(p.id)} + {@render row("check", "Kept offline", "every streamed track fetched and kept on this phone — tap to stop keeping it", act(() => { void setOffline(p, false); }))} + {:else} + {@render row("download", "Keep offline", "every streamed track fetched in the background and kept, so it plays in the car with the app shut", act(() => { void setOffline(p, true); }))} + {/if} + {/if} {#if kind === "playlist-more" && bulkable(rows) && !bulk.active} {@render row("library", "Add to library", "files every already-streamed track — nothing is downloaded", act(() => { void runBulk(p, rows, "cached"); }))} diff --git a/src/mobile/Mobile.svelte b/src/mobile/Mobile.svelte index a214f0d..33458ea 100644 --- a/src/mobile/Mobile.svelte +++ b/src/mobile/Mobile.svelte @@ -37,6 +37,8 @@ import Diagnostics from "./screens/Diagnostics.svelte"; import { transfers, bindTransfers, active as activeTransfers } from "./transfers.svelte.js"; import { xfernote, syncShade, bindXferNote } from "./xfernote.svelte.js"; + import { offline, bindOffline, setOffline, isOffline, cancelOffline } from "./offline.svelte.js"; + import { syncStreamSourcesToBackend } from "../lib/sources.js"; import { home, bindHome, loadHome } from "./home.svelte.js"; import { lyr, loadLyrics } from "./lyrics.svelte.js"; import Stub from "./screens/Stub.svelte"; @@ -176,7 +178,12 @@ it — the walk, with nothing fetched — drives the notice exactly as a real one does. It reads the store and never the notice's own record, which is why that record is a plain object. */ - $effect(() => { syncShade(); }); + $effect(() => { + /* the offline worker rides the same notice: read what it shows so a + change of row or of progress re-posts the shade */ + void offline.active; void offline.done; void offline.title; void offline.percent; + syncShade(); + }); /* ── the first run stamps when it goes away, whatever closed it ──── The foot's button calls finishSetup itself; the scrim, the back @@ -300,6 +307,11 @@ watchLibrary(); bindTransfers(); bindXferNote(); + /* the playlists kept offline, and the stream-source mirror the cold + media service reads: memories made before the mirror existed reach + it once, here, so cache files a car could play are named */ + void bindOffline(); + setTimeout(() => { try { syncStreamSourcesToBackend(); } catch { /* fine */ } }, 3000); bindHome(); bindImporter(); bindShare(); @@ -323,7 +335,7 @@ requestAnimationFrame(() => requestAnimationFrame(() => { clearTimeout(sealTimer); sealBoot(); })); /* 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, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms }; + window.__ss = { nav, top, go, push, back, openSheet, closeSheet, openHalf, closeHalf, player, lib, art, catalog, home, loadHome, lyr, loadLyrics, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, card: mediacard, pls, notice, importer, artTally, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms, offline, offlineCtl: { setOffline, isOffline, cancelOffline } }; return () => { if (unlisten) unlisten(); }; }); diff --git a/src/mobile/offline.svelte.js b/src/mobile/offline.svelte.js new file mode 100644 index 0000000..083b10a --- /dev/null +++ b/src/mobile/offline.svelte.js @@ -0,0 +1,259 @@ +/* Keep a playlist offline: every streamed row fetched into the stream cache + in the background and pinned there, so the whole list plays with the app + shut — in a car, in a pocket — the way a downloaded playlist does in any + other player. + + WHAT IT IS NOT. Not the library: "Download all tracks" (playlists.svelte.js + runBulk) files every row as a real album in the library, which is Save. + This is the OTHER promise: the playlist stays a playlist of streamed rows, + each one simply already here. The bytes land where a stream's bytes always + land (the stream cache, by the same peer file), the row's memory of which + peer file that is goes to the Rust side too (stream_sources.rs), and the + pruner leaves a pinned file alone however old it gets. + + HOW IT RUNS. One pass at a time, one row at a time, in the background of + the page: the remembered peer file first (no search), else the resolver's + ladder over the network — the same source rules a play uses, so what is + kept is what would have played — then the cache fetch the player itself + uses for the next track (preload_stream), polled until the file is whole. + Never two fetches at once and never competing with a search the player is + running: the ladder's own pacing holds, and the row being PLAYED right now + is skipped (a second transfer of the file a peer is already streaming to + us deadlocks both, save_to_library learned that). + + WHEN. After boot once the library and the account have answered, again + whenever the account (re)connects, and whenever a playlist changes. Wi-Fi + only when the switch says so (network.svelte.js), like every download. + + IN THE SHADE. While it fetches, the downloads notice (xfernote.svelte.js) + carries it — which is also what keeps the process alive once the app is + in the background: without a foreground service Android freezes the + process a minute after the screen leaves it and every fetch stalls until + the app is opened again. Android 12+ only lets that service START from + the foreground, so a pass that begins on screen keeps going in a pocket; + one that begins in the background (a reconnect while the app is behind + another) runs only while the process is awake. */ +import * as api from "../lib/api.js"; +import { listen } from "../lib/events.js"; +import { lib } from "./lib.svelte.js"; +import { slsk, awaitOnline } from "./slsk.svelte.js"; +import { player } from "./player.svelte.js"; +import { + resolveSources, songCandidates, simplifyTitle, cachedStreamSourceFor, rememberStreamSource, + loadStreamSources, streamSourceKeys, normText, qualityOf, +} from "../lib/sources.js"; +import { downloadsAllowed } from "./network.svelte.js"; +import { note } from "./notice.svelte.js"; + +export const offline = $state({ + /** playlist ids kept offline — the config's list, read at boot */ + ids: [], + /** a pass is running */ + active: false, + /** the playlist and row it is on */ + playlistId: null, + title: "", + /** rows checked and rows to check, this pass */ + done: 0, + total: 0, + /** the in-flight fetch: 0–100, or -1 while nothing is in flight */ + percent: -1, + /** rows this pass could not source */ + missed: 0, + /** playlist id → { here, total }: what the header says once a pass has looked */ + ready: {}, +}); + +export const isOffline = (id) => offline.ids.some((x) => Number(x) === Number(id)); + +let cancel = false; +let timer = null; +/** the pass that is running, so a second ask waits for it rather than racing it */ +let running = null; + +export async function loadOffline() { + try { offline.ids = (await api.playlistOfflineList()) || []; } catch { /* a desktop, or the mock */ } +} + +/** Switch a playlist's keep on or off. On: a pass starts at once. Off: the + * rows' pins are lifted so the pruner may reclaim them in its own time; + * nothing is deleted here — a track kept for months does not vanish the + * second the switch flips. */ +export async function setOffline(p, keep) { + try { offline.ids = (await api.playlistOfflineSet(p.id, keep)) || []; } catch (e) { player.error = String(e); return; } + if (keep) { + note(`Keeping “${p.name}” offline`); + schedule(0); + } else { + note(`No longer keeping “${p.name}” offline`); + delete offline.ready[p.id]; + void unpin(p); + } +} + +export function cancelOffline() { cancel = true; } + +async function unpin(p) { + let items = []; + try { items = (await api.playlistItems(p.id)) || []; } catch { return; } + for (const r of items) { + if (r.kind !== "stream" || !r.artist || !r.title) continue; + api.streamSourceKeep(r.track_id ?? null, r.artist, r.title, false).catch(() => {}); + } +} + +/** A pass, soon. Coalesced: a burst of playlist changes is one pass. */ +export function schedule(ms = 1500) { + clearTimeout(timer); + timer = setTimeout(() => { void run(); }, ms); +} + +/** The remembered peer file for a row, whole or not — the fetch wants the + * identity even when the bytes are not all here yet. */ +function rememberedFor(tid, artist, title) { + const map = loadStreamSources(); + for (const key of streamSourceKeys(tid, artist, title)) { + const src = map.get(key); + if (src?.username && src.path && src.size > 0) return src; + } + return null; +} + +function playingNow(username, path) { + const t = player.track; + return !!t?.stream && t.stream.username === username && t.path === path + && (player.status === "playing" || player.status === "buffering"); +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +/** Wait for a fetch to be whole, reporting its progress; false on a stall + * (no byte for a while) or a cancel. */ +async function awaitWhole(src) { + const startedAt = Date.now(); + let lastLen = -1; + let lastGrowAt = Date.now(); + for (;;) { + if (cancel) return false; + let len = null; + try { len = await api.streamCacheLen(src.username, src.path); } catch { len = null; } + if (len != null && len >= src.size) return true; + if (len != null && len !== lastLen) { lastLen = len; lastGrowAt = Date.now(); } + offline.percent = src.size ? Math.min(99, Math.round(((len || 0) / src.size) * 100)) : -1; + /* a peer that has queued us can take minutes to start; a peer that + started and stopped is not coming back — two different patiences */ + const quiet = Date.now() - lastGrowAt; + if ((lastLen > 0 && quiet > 90_000) || (lastLen <= 0 && quiet > 8 * 60_000) || Date.now() - startedAt > 30 * 60_000) return false; + await sleep(3000); + } +} + +async function run() { + if (running) return running; + running = (async () => { + cancel = false; + if (!offline.ids.length) return; + if (!downloadsAllowed()) return; + offline.active = true; + offline.done = 0; + offline.total = 0; + offline.missed = 0; + offline.percent = -1; + try { + /* every stream row of every kept playlist, once each */ + const work = []; + const seen = new Set(); + for (const id of offline.ids) { + const p = lib.playlists.find((x) => Number(x.id) === Number(id)); + let items = []; + try { items = (await api.playlistItems(id)) || []; } catch { continue; } + let total = 0; + for (const r of items) { + if (r.kind !== "stream" || !r.artist || !r.title) continue; + total++; + const key = `${normText(r.artist)}|${normText(r.title)}`; + if (seen.has(key)) continue; + seen.add(key); + work.push({ playlistId: Number(id), name: p?.name || "", row: r }); + } + offline.ready[id] = { here: offline.ready[id]?.here ?? 0, total }; + } + offline.total = work.length; + const here = {}; + for (const w of work) { + if (cancel) break; + const { row } = w; + offline.playlistId = w.playlistId; + offline.title = row.title; + offline.percent = -1; + const tid = row.track_id != null && row.track_id !== "" ? Number(row.track_id) : null; + try { + /* already whole: pin it and move on */ + const whole = await cachedStreamSourceFor(tid, row.artist, row.title); + if (whole) { + api.streamSourceKeep(tid, row.artist, row.title, true).catch(() => {}); + here[w.playlistId] = (here[w.playlistId] || 0) + 1; + continue; + } + /* the remembered peer file, else a search of our own */ + let src = rememberedFor(tid, row.artist, row.title); + if (!src) { + if (slsk.connection !== "online" && !(await awaitOnline(slsk.connection === "connecting" ? 20_000 : 0))) { offline.missed++; continue; } + const { responses, artistIncluded } = await resolveSources( + `${row.artist} — ${row.title}`, + [`${row.artist} ${simplifyTitle(row.title)}`, simplifyTitle(row.title)], + (rs) => songCandidates(rs, row.title, true, row.album ?? null, row.duration_secs ?? null, row.artist).length, + row.artist, + null, + ); + const best = songCandidates(responses, row.title, true, row.album ?? null, row.duration_secs ?? null, row.artist, !artistIncluded)[0]; + if (!best) { offline.missed++; continue; } + src = { username: best.username, path: best.file.path, size: best.file.size, duration: best.file.duration ?? null, quality: qualityOf(best.file) }; + rememberStreamSource({ stream: { username: src.username, size: src.size }, path: src.path, title: row.title, artist: row.artist, trackId: tid, duration: src.duration, quality: src.quality }); + } + if (cancel) break; + if (playingNow(src.username, src.path)) { continue; } /* the player is fetching it */ + if (slsk.connection !== "online" && !(await awaitOnline(slsk.connection === "connecting" ? 20_000 : 0))) { offline.missed++; continue; } + try { await api.preloadStream(src.username, src.path, src.size); } catch { offline.missed++; continue; } + if (await awaitWhole(src)) { + api.streamSourceKeep(tid, row.artist, row.title, true).catch(() => {}); + here[w.playlistId] = (here[w.playlistId] || 0) + 1; + } else { + offline.missed++; + } + } catch { + offline.missed++; + } finally { + offline.done++; + offline.percent = -1; + } + await sleep(500); + } + for (const id of offline.ids) { + if (offline.ready[id]) offline.ready[id] = { ...offline.ready[id], here: here[id] || 0 }; + } + } finally { + offline.active = false; + offline.playlistId = null; + offline.title = ""; + offline.percent = -1; + } + })().finally(() => { running = null; }); + return running; +} + +let bound = false; +/** Wire the passes to what should start one. Harmless where nothing can. */ +export async function bindOffline() { + if (bound) return; + bound = true; + await loadOffline(); + if (offline.ids.length) schedule(12_000); + try { + await listen("soulseek:playlists-changed", () => { if (offline.ids.length) schedule(4000); }); + await listen("soulseek:state", (ev) => { if (ev.payload?.kind === "connected" && offline.ids.length) schedule(6000); }); + } catch { /* not a phone */ } + /* a long session: look again every half hour, for rows a peer had gone + offline for the last time */ + setInterval(() => { if (offline.ids.length && !offline.active) schedule(0); }, 30 * 60_000); +} diff --git a/src/mobile/screens/Playlist.svelte b/src/mobile/screens/Playlist.svelte index c3561c2..3b6cbde 100644 --- a/src/mobile/screens/Playlist.svelte +++ b/src/mobile/screens/Playlist.svelte @@ -14,6 +14,7 @@ import { player } from "../player.svelte.js"; import { openHalf } from "../nav.svelte.js"; import { pls, bulk, cancelBulk, dismissBulk, coverFor, isSetlist, queueable, unresolvedCount, missingCount, totalSecs, playFrom, shufflePlay, rowClick, moveItem } from "../playlists.svelte.js"; + import { offline, isOffline, cancelOffline } from "../offline.svelte.js"; import { say } from "../trouble.js"; import { mmss, longDur, stamp } from "../util.js"; import { reorder } from "../reorder.js"; @@ -126,6 +127,18 @@ + {#if isOffline(pl.id)} +