android: a playlist file, picked
The storage plugin grew `pick_file` beside `pick_folder` and `pick_image`: the document sheet with the caller's own filter, answered with a path Rust can open. A document already on this phone hands its path over as it lies — the document sibling of the same volume:relative id the folder picker resolves, plus the Downloads provider's raw: — so a 300 MB pick copies nothing; one that has no path is copied into cacheDir/picked under its own name, and only as far as the caller allows, because a playlist is kilobytes and a DJ set is not. Those copies are swept at the start of every pick, which pick_image's never were. The import sheet has its fifth door, on round 24's screen, wait card and review, and the note that apologised for its absence says the one true thing about it instead. `playlist_import_probe` took a path and a name the way `playlist_import_folder` took a path, and the name is the load-bearing half: the parser picks its format off the extension and titles the draft from the stem, so a cache copy under an invented name would have read every playlist as plain text. The mock walk drives the door and both of the picker's refusals through round 31's fail door; the phone walk opens the real sheet and dismisses it with BACK, reading nothing and writing nothing. The brief asked for a second consumer — sending a file in a chat — and there is nothing behind it: `community_pick_file` is the persona layer's three native dialogs, `soulseek-client` has `say_room` and `message_user` and nothing that sends a file, and both frontends' thread composers are text only. Nothing was invented for it. The plugin command is general enough — a caller's own filter, a zero-copy path, a bounded copy — that the one reader still waiting, the local set listener the phone puts off by design, needs no further plugin change. Playlist export is not on that list and never was: writing a document out is the share plugin's errand, not a picker's. The Kotlin is unbuilt — nothing on this machine compiles it.
This commit is contained in:
1 parent
04c87e0db1
commit
defd8c5e6d
16 files changed
+559
-57
No files matched your search
+15
-2
@@ -279,9 +279,14 @@ export const playlistCover = (id, maxEdge = null) =>
|
||||
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
|
||||
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
|
||||
export const playlistCoverSet = (id, path) => invoke("playlist_cover_set", { id, path });
|
||||
// Import: probe opens a file dialog and stages a reviewed draft; text stages
|
||||
// Import: probe stages a reviewed draft from a playlist file; text stages
|
||||
// a pasted tracklist; commit writes the accepted draft in one transaction.
|
||||
export const playlistImportProbe = () => invoke("playlist_import_probe");
|
||||
// The desktop passes neither argument and opens its own dialog; the phone
|
||||
// picks with the storage plugin and hands the path in — with the picked
|
||||
// document's own name, which is what chooses the parser and titles the
|
||||
// draft (a copied file is not always named what the document was).
|
||||
export const playlistImportProbe = (path = null, name = null) =>
|
||||
invoke("playlist_import_probe", { path, name });
|
||||
// The desktop opens its own folder dialog (no path); the phone picks with
|
||||
// the storage plugin and hands the resolved path in.
|
||||
export const playlistImportFolder = (path = null) => invoke("playlist_import_folder", { path });
|
||||
@@ -519,6 +524,14 @@ export const transferNotice = (state) => invoke("plugin:transfer-notice|update",
|
||||
export const clearTransferNotice = () => invoke("plugin:transfer-notice|clear");
|
||||
// A photo from the phone (the storage plugin): { path } in the app's cache.
|
||||
export const pickImage = () => invoke("plugin:storage|pick_image");
|
||||
// A FILE from the phone (the storage plugin): { path, name, size, copied }.
|
||||
// `mimeTypes` is the document sheet's filter, comma-separated; `maxCopyBytes`
|
||||
// is how big a document may be copied into the app's cache when it has no
|
||||
// path of its own on this phone — 0 refuses the copy, which is what an
|
||||
// audio-sized pick wants. The name comes back beside the path because a
|
||||
// copy is named after the document and only the document's name is it.
|
||||
export const pickFile = (mimeTypes, maxCopyBytes = 0) =>
|
||||
invoke("plugin:storage|pick_file", { mimeTypes, maxCopyBytes });
|
||||
// Download a found album copy into (Cache)\_audition\<rel>\ for the version
|
||||
// comparison. Called track 1 first, then the rest — `startIndex` keeps the
|
||||
// second batch's indices continuing, since index 0 means "track 1".
|
||||
|
||||
@@ -328,6 +328,21 @@ function mockImport(cmd, args) {
|
||||
}, 600);
|
||||
});
|
||||
}
|
||||
if (cmd === "playlist_import_probe") {
|
||||
const path = String(args?.path || "");
|
||||
if (!path) return Promise.resolve(null); /* the desktop's dialog, dismissed */
|
||||
/* the NAME decides the format, the way playlist_formats::detect_format
|
||||
does — the extension first, the stem as the draft's title. The path
|
||||
is only where the bytes were. */
|
||||
const name = String(args?.name || path.split(/[\\/]/).filter(Boolean).pop() || "Playlist");
|
||||
const ext = (name.split(".").pop() || "").toLowerCase();
|
||||
const format = ext === "m3u" || ext === "m3u8" ? "m3u" : ext === "pls" ? "pls" : ext === "xspf" ? "xspf"
|
||||
: ext === "csv" ? "csv" : ext === "json" ? "soulstream" : "text";
|
||||
/* the paste door's own three lines, so the verdicts are already
|
||||
proven: one in the library, one stream, one unresolved */
|
||||
const entries = impParse("Daft Punk - One More Time\nAphex Twin - Track 1\nID - ID");
|
||||
return new Promise((r) => setTimeout(() => r(impDraft(entries, name, format, name.replace(/\.[^.]*$/, ""))), 500));
|
||||
}
|
||||
if (cmd === "playlist_import_folder") {
|
||||
const root = String(args?.path || "");
|
||||
if (!root) return Promise.resolve(null); /* the desktop's dialog, dismissed */
|
||||
@@ -784,6 +799,13 @@ function mockCatalog(cmd, args) {
|
||||
if (cmd === "plugin:storage|all_files_access") return Promise.resolve({ granted: !fresh });
|
||||
if (cmd === "plugin:storage|request_all_files_access") return Promise.resolve();
|
||||
if (cmd === "plugin:storage|pick_image") return Promise.resolve({ path: "C:\\mock\\photo.jpg" });
|
||||
/* a document that HAD to be copied, with the two names apart: the cache
|
||||
file is safeName()'s — every character outside the plugin's allowed
|
||||
set replaced, so the "#" is an underscore there — and the document's
|
||||
own is what the draft must be read and titled by. Same on purpose:
|
||||
if the name stopped travelling, the path's basename would still say
|
||||
m3u8 and only the title would quietly go wrong, which is the bug. */
|
||||
if (cmd === "plugin:storage|pick_file") return Promise.resolve({ path: "C:\\mock\\picked\\Warehouse set _3.m3u8", name: "Warehouse set #3.m3u8", size: 2048, copied: true });
|
||||
/* the downloads notice: the mock walk cannot see the shade, so the calls
|
||||
are simply answered — what the walk reads is __ss.xfn, the app's own
|
||||
record of having posted and cleared */
|
||||
|
||||
@@ -14,7 +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 { DOORS, start as startImport, pickImportFolder, startFolder } from "./importer.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";
|
||||
import { lyr, nudge, searchCandidates, closeCandidates, chooseCandidate, forgetLyrics, pasteLyrics, setAutoSync, openTapSync, tapProgress, reduceVocals, backToOriginal } from "./lyrics.svelte.js";
|
||||
@@ -175,6 +175,18 @@
|
||||
onsubmit: (v) => { push("import", {}); void startImport(door, v); },
|
||||
});
|
||||
}
|
||||
/* the playlist-file door picks FIRST too: the review only opens for a
|
||||
file that was chosen, and a document this phone can hand no path for
|
||||
is refused with the reason rather than half-read */
|
||||
async function fileDoor() {
|
||||
closeHalf();
|
||||
try {
|
||||
const picked = await pickImportFile();
|
||||
if (!picked) return;
|
||||
push("import", {});
|
||||
void startFile(picked.path, picked.name);
|
||||
} catch (e) { oops(e); }
|
||||
}
|
||||
/* the folder door picks FIRST: the review only opens for a folder that
|
||||
was chosen, and a tree with no path on this phone cannot be walked */
|
||||
async function folderDoor() {
|
||||
@@ -556,10 +568,10 @@
|
||||
<div class="sheet-title">Import a playlist</div>
|
||||
<div class="sheet-rows">
|
||||
{#each DOORS as [door, icon, t, m] (door)}
|
||||
{@render row(icon, t, m, () => (door === "folder" ? folderDoor() : importPrompt(door)))}
|
||||
{@render row(icon, t, m, () => (door === "folder" ? folderDoor() : door === "file" ? fileDoor() : importPrompt(door)))}
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sec-note" style="padding-bottom: 14px">A playlist file — M3U, XSPF, PLS or CSV — imports on the desktop; the phone has no file picker for one yet.</div>
|
||||
<div class="sec-note" style="padding-bottom: 14px">A playlist file's rows are matched against the library on this phone — by path where the file names one, and by filename otherwise, so a file copied here from another machine still finds what you have.</div>
|
||||
{:else if kind === "plmatch" && shown?.arg?.item}
|
||||
{@const p = shown.arg.playlist}
|
||||
{@const item = shown.arg.item}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
import { notice, clearNote } from "./notice.svelte.js";
|
||||
import { say, signedOut } from "./trouble.js";
|
||||
import * as pls from "./playlists.svelte.js";
|
||||
import { importer, bindImporter, startFolder } from "./importer.svelte.js";
|
||||
import { importer, bindImporter, startFolder, startFile } from "./importer.svelte.js";
|
||||
import { bindShare } from "./share.svelte.js";
|
||||
import { downloadsAllowed, cellular } from "./network.svelte.js";
|
||||
import { contacts, loadContacts, display } from "./contacts.svelte.js";
|
||||
@@ -305,7 +305,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, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder }, 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, slsk, boot, net, resolver, streamSong, streamCatalogAlbum, sources, api, transfers, xfn: xfernote, pls, notice, importer, imp: { startFolder, startFile }, setup, openSetup, setupState, wifi: { downloadsAllowed, cellular }, next, prev, toggle, seekSecs, playQueueAt, patch, contacts, loadContacts, say, refreshRooms };
|
||||
return () => { if (unlisten) unlisten(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/* Importing a playlist on the phone — the desktop's PlaylistImportModal's
|
||||
rules, on the doors that need no file picker: a link (YouTube · Spotify ·
|
||||
Apple Music · TIDAL · Deezer), a pasted tracklist (plain lines, a
|
||||
timestamped setlist, or track links — a paste holding one is read as
|
||||
links), a YouTube DJ set's posted tracklist, and a folder of audio files
|
||||
rules, on every door it has: a playlist file off this phone (M3U · M3U8 ·
|
||||
PLS · XSPF · CSV · JSON), a link (YouTube · Spotify · Apple Music ·
|
||||
TIDAL · Deezer), a pasted tracklist (plain lines, a timestamped setlist,
|
||||
or track links — a paste holding one is read as links), a YouTube DJ
|
||||
set's posted tracklist, and a folder of audio files
|
||||
on this phone. The backend stages a draft (parsing and resolution against
|
||||
the library); nothing is written until commit. While the review is up
|
||||
the stream rows are verified against the catalog — the desktop's
|
||||
@@ -14,7 +15,7 @@ import { findCatalogTrack } from "../lib/catalogMatch.js";
|
||||
import { refreshPlaylists } from "./lib.svelte.js";
|
||||
|
||||
export const importer = $state({
|
||||
door: null, // "link" | "text" | "youtube" | "folder"
|
||||
door: null, // "file" | "link" | "text" | "youtube" | "folder"
|
||||
source: "", // what was pasted
|
||||
busy: false,
|
||||
error: "",
|
||||
@@ -39,10 +40,10 @@ export const FORMAT_LABEL = {
|
||||
link path, so a list copied out of Spotify never parses as track names. */
|
||||
const TRACK_LINK = /(?:spotify:track:[A-Za-z0-9]{22})|(?:\/\/(?:open\.spotify\.com\/(?:[\w-]+\/)*track\/|(?:www\.)?deezer\.com\/(?:[\w-]+\/)*track\/|(?:[\w-]+\.)?tidal\.com\/(?:[\w-]+\/)*track\/|music\.apple\.com\/[^\s]*(?:\/song\/|[?&]i=)))/i;
|
||||
|
||||
/** [door, icon, title, hint] — the desktop's Import menu, minus the
|
||||
* playlist-file door (that one still wants a file picker). A YouTube
|
||||
* playlist link goes through "link": the backend dispatches it. */
|
||||
/** [door, icon, title, hint] — the desktop's Import menu, all five doors.
|
||||
* A YouTube playlist link goes through "link": the backend dispatches it. */
|
||||
export const DOORS = [
|
||||
["file", "file-text", "Playlist file…", "M3U · M3U8 · PLS · XSPF · CSV · JSON"],
|
||||
["link", "link", "Playlist from a link…", "YouTube · Spotify · Apple Music · TIDAL · Deezer"],
|
||||
["text", "pencil", "Paste a tracklist…", "plain lines, a timestamped setlist, or track links"],
|
||||
["youtube", "brand-youtube", "YouTube DJ set…", "tracks from chapters, description or comments"],
|
||||
@@ -72,6 +73,26 @@ export async function start(door, source) {
|
||||
if (g === gen) { importer.error = String(e); importer.busy = false; }
|
||||
}
|
||||
}
|
||||
/** The extensions the backend's parser knows; anything else is refused here
|
||||
* rather than read as "pasted text" with a nonsense title. */
|
||||
const PLAYLIST_EXT = /\.(m3u8?|pls|xspf|csv|json|txt)$/i;
|
||||
/** The playlist-FILE door picks FIRST, like the folder door — the phone's
|
||||
* own document picker (the storage plugin's `pick_file`). Every type is
|
||||
* offered on purpose: Android's providers report m3u and pls under half a
|
||||
* dozen types and often as plain bytes, so a filter that names one greys
|
||||
* out the very file being looked for; the extension is checked here
|
||||
* instead. A document with no path on this phone is copied into the app's
|
||||
* cache under its own name, up to the size the parser itself refuses.
|
||||
* Returns { path, name } — or null when the picker was dismissed. */
|
||||
export async function pickImportFile() {
|
||||
let picked;
|
||||
try { picked = await api.pickFile("*/*", 8 * 1024 * 1024); }
|
||||
catch (e) { if (/cancelled/.test(String(e))) return null; throw e; }
|
||||
if (!picked?.path) throw new Error("That file isn't on this phone's storage — copy it here first, then import it.");
|
||||
const name = picked.name || "";
|
||||
if (!PLAYLIST_EXT.test(name)) throw new Error("“" + (name || "That file") + "” isn't a playlist file — M3U, M3U8, PLS, XSPF, CSV, JSON or TXT.");
|
||||
return { path: picked.path, name };
|
||||
}
|
||||
/** The folder door picks FIRST — the phone's own document-tree picker — so
|
||||
* the review only opens for a folder that was actually chosen. The tree has
|
||||
* to be on this phone's storage: the backend walks a path, and a cloud
|
||||
@@ -83,6 +104,28 @@ export async function pickImportFolder() {
|
||||
if (!picked?.path) throw new Error("That folder has no path the phone can read — pick one on this phone's storage.");
|
||||
return picked.path;
|
||||
}
|
||||
/** Stage a playlist FILE. The name travels beside the path rather than
|
||||
* being read off it: the picked document's own name is what chooses the
|
||||
* parser and titles the draft, and a document the phone had no path for
|
||||
* arrives as a cache copy. Rows naming a file by a relative path resolve
|
||||
* against the file's own folder; a copied file's rows fall back to the
|
||||
* library's basename index, so they still find what this phone holds. A
|
||||
* playlist file does produce stream rows, so the catalog pass runs. */
|
||||
export async function startFile(path, name) {
|
||||
const g = ++gen;
|
||||
Object.assign(importer, { door: "file", source: path, busy: true, error: "", progress: null, draft: null, name: "", includeUnresolved: true, checks: {}, committing: false, links: false });
|
||||
try {
|
||||
const draft = await api.playlistImportProbe(path, name);
|
||||
if (g !== gen) return;
|
||||
importer.busy = false;
|
||||
if (!draft) { importer.error = "Nothing came back from that file."; return; }
|
||||
importer.draft = draft;
|
||||
importer.name = draft.name ?? "";
|
||||
if (draft.entries?.length) queueMicrotask(() => void verifyStreams(g));
|
||||
} catch (e) {
|
||||
if (g === gen) { importer.error = String(e); importer.busy = false; }
|
||||
}
|
||||
}
|
||||
/** Stage a folder of audio files. The backend walks the folder and its disc
|
||||
* subfolders, orders by the files' own track numbers when every one carries
|
||||
* a distinct one and by natural filename order otherwise, and matches each
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
function leave() { cancel(); back(); }
|
||||
const waitLine = $derived(importer.door === "link" ? "Reading the link…"
|
||||
: importer.door === "youtube" ? "Reading the set's tracklist…"
|
||||
: importer.door === "file" ? "Reading the playlist file…"
|
||||
: importer.door === "folder" ? "Reading the folder…"
|
||||
: importer.links ? "Reading the pasted links…"
|
||||
: "Reading the tracklist…");
|
||||
|
||||
Reference in new issue
Block a user