diff --git a/ANDROID.md b/ANDROID.md index 6c9e696..6d7ade2 100644 --- a/ANDROID.md +++ b/ANDROID.md @@ -237,7 +237,11 @@ Library clears the play history behind a confirm. The now-playing sheet's ⋯ on a streamed track is the desktop's Save menu: Save to library (at once when the cache is byte-complete, else through the download pipeline) and Save this record for the streamed -record the queue holds. A moment after boot the phone warms the library's thumbnails the +record the queue holds; beside them, "Copy to a folder…" writes that same +cached stream into a folder picked on this phone, as the plain file it is +(`copy_stream_to_folder` — no re-download, no tagging, no library row; a +track that has not finished streaming is counted back, never fetched). +A moment after boot the phone warms the library's thumbnails the 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, @@ -422,6 +426,29 @@ the lyric alignment models (a desktop runtime), artist artwork's upscaler. Search's empty page lists the recent searches this phone used (the last eight: Enter, or the screen left with a query in it), kept on the device. +Sharing carries one thing the desktop never exposed at all: the listening +port (`listenPort`), what other peers dial when they download from you. +Unset means the client's own 50300 (`DEFAULT_LISTEN_PORT`; bind walks +forward a few ports if it is taken); the app asks the router to forward it +over UPnP and falls back to the server relaying the connection, and a +change applies on the next connect — on mobile data the carrier's NAT +blocks incoming connections whatever the number is. `set_settings` only +assigns a port that is Some, so it can be changed but never taken back to +unset: the phone walk saves a spare number and then 50300, which is the +port an unset config already used. + +The dev flavour gets one more row under This phone: Diagnostics +(`screens/Diagnostics.svelte`), the desktop's Ctrl+Shift+D overlay as a +screen — process and system CPU, memory, the network rates, the track +playing with its loudness correction, the tracks the pre-buffer is +fetching, and how big the play history has grown (`debug_stats`, +`play_row_count`), polled once a second while it is open and never +otherwise. `devBuild` in `util.js` is the gate: Vite's own DEV flag, true +only for the build that loads from the dev server over the cable, so the +embedded build the phone keeps has no row to tap. The GPU counters are +Windows PDH and the private commit is a Win32 call, so neither is on the +screen — off Windows they report None and 0. + ## Background playback and the lock-screen card The service is started with `startForegroundService`, so it promotes itself diff --git a/src/lib/mockLibrary.js b/src/lib/mockLibrary.js index f5c78f8..09c77d1 100644 --- a/src/lib/mockLibrary.js +++ b/src/lib/mockLibrary.js @@ -597,6 +597,34 @@ function mockCatalog(cmd, args) { } if (cmd === "preload_cached_stream") return Promise.resolve(cacheReady.has(args?.path)); if (cmd === "excluded_search_phrases") return Promise.resolve([]); + if (cmd === "copy_stream_to_folder") { + /* the real command copies only what the stream cache already holds */ + let saved = 0, missing = 0; + for (const f of args?.files || []) { if (cacheReady.has(f.path)) saved++; else missing++; } + return Promise.resolve([saved, missing]); + } + if (cmd === "play_row_count") return Promise.resolve(4210); + if (cmd === "debug_stats") { + /* the overlay's shape, camelCase as serde renames it; the numbers + wander a little so the screen is seen to be live */ + const t = Date.now() / 1000; + return Promise.resolve({ + cpuProcess: 7.5 + Math.sin(t), cpuSystem: 22 + Math.cos(t) * 3, + gpuPercent: null, gpuAppPercent: null, + memWorkingSet: 431_000_000, memPrivate: 0, + memSystemUsed: 5_400_000_000, memSystemTotal: 7_900_000_000, + netRxBps: 1_250_000, netTxBps: 96_000, + processCount: 3, uptimeSecs: 742, + dlReceived: xfer ? Math.round(xfer.received) : null, + dlTotal: xfer ? xfer.total : null, + dlDone: xfer ? xfer.received >= xfer.total : null, + normalizationEnabled: mockSettings.volume_normalization !== false, targetLufs: -14, + loudness: { lufs: -9.8, peak: 0.98, gainDb: -4.2, cached: true, analyzing: false, source: "cache", headStartDb: 0, prefixGainDb: null }, + mainFadeGain: 1, normNowDb: -4.2, normTargetDb: -4.2, + previewVolume: null, previewNormDb: null, + preloadDl: [{ username: "dusty_crates", path: "Music\\Daft Punk\\Discovery\\02 Aerodynamic.flac", state: "transferring", place: null, received: 5_000_000, total: 12_000_000, speedBps: 850_000 }], + }); + } if (cmd === "get_settings") return Promise.resolve({ ...mockSettings, stream_source_prefs: { ...mockSettings.stream_source_prefs }, download_source_prefs: { ...mockSettings.download_source_prefs } }); if (cmd === "set_settings") { /* the command's arguments are camelCase; the config's fields are snake_case */ @@ -624,6 +652,7 @@ const mockSettings = { library_layout: "{artist}\\{album}", shared_folders: [], upload_slots: 2, + listen_port: null, share_files: 1234, word_timing: false, replace_embedded_art: true, diff --git a/src/mobile/HalfSheet.svelte b/src/mobile/HalfSheet.svelte index c238774..214be82 100644 --- a/src/mobile/HalfSheet.svelte +++ b/src/mobile/HalfSheet.svelte @@ -214,6 +214,24 @@ : "Nothing to save"); } catch (e) { oops(e); } } + /* the desktop's copy_stream_to_folder: the cached stream written into a + folder picked on this phone, as the file it is — no re-download, no + tagging, no library row. Nothing leaves the phone, so the Wi-Fi guard + has no say here. A track that has not finished streaming is counted + back as missing, never fetched. */ + async function copyToFolder(entries) { + const files = entries.filter((e) => e.t?.stream && e.t.path).map((e) => ({ username: e.t.stream.username, path: e.t.path })); + if (!files.length) return; + try { + const r = await api.pickLibraryFolder(); + if (!r?.path) { player.error = "That folder has no path the phone can write to — pick one on this phone's storage."; return; } + const [saved, missing] = await api.copyStreamToFolder(files, r.path); + note(saved ? saved + " file" + (saved === 1 ? "" : "s") + " copied" + (missing ? " · " + missing + " not cached yet" : "") + : "Not cached yet — play it through first"); + } catch (e) { + if (!/cancelled/.test(String(e))) oops(e); + } + } /* a peer's sheet (a raw result's folder, or the browse screen's user) */ function playGroup(g) { const es = groupEntries(g, 0); @@ -346,6 +364,7 @@ {#if streamMates(track, album).length > 1} {@render row("download", "Save this record", streamMates(track, album).length + " tracks streaming from the same record", act(() => saveStreams(streamMates(track, album))))} {/if} + {@render row("copy", "Copy to a folder…", "the streamed file as it is, into a folder on this phone", act(() => copyToFolder([{ t: track, album }])))}
{/if} {@render row("play", "Play next", "", act(() => playNext([{ t: track, album }])))} @@ -487,7 +506,7 @@ {#if a.multiline} {:else} - { if (e.key === "Enter") submitPrompt(); }} use:focusLater /> + { if (e.key === "Enter") submitPrompt(); }} use:focusLater /> {/if}