The desktop's two playlist pages on the phone: the shelf (pinned first, mosaic covers, badges, New playlist, Import) and the page (kind, Pinned chip, description, the meta line's warnings, Play / Shuffle / more, a filter, rows tagged unresolved / set audio / file missing). Sheets for a card, the page's overflow, a row, pick-a-playlist, a prompt and a confirm; Add to playlist on every track and record sheet; a removal undoable for seven seconds from the neutral toast (notice.svelte.js); stream rows play through the resolver as path-less entries, a set slice starts and ends at its cues; Add to library / Download all tracks with the desktop's bulk rules; nav.replace for a review that became the thing it made. The import doors that need no file — a link (YouTube · Spotify · Apple Music · TIDAL · Deezer), a pasted tracklist, a YouTube DJ set — with the review screen and the desktop's catalog verification pass. A share plugin crate (crates/tauri-plugin-share): Share → SoulStream from any app keeps the text in a ShareActivity and the WebView hands it to the import when it comes back to the front. You → Sources: the source-quality window and the priority per mode. Found by the walks and fixed: set_settings takes camelCase arguments, so the phone's normalization and library-folder saves never landed; Cover.svelte's non-eager wrapper has no layout box, so its intersection observer never fired and row thumbnails never loaded (the Artists tab too); a history push while a history.go was still in flight left the nav mirror one entry ahead and the page blank after a confirmed delete — the mirror now applies once per microtask and never pushes during a pending traversal. The harness names unmocked commands, carries console warnings' frames (the smoke bundle is built unminified), probes the history mirror on every step, mocks app_status / play_local / waveform / lyrics_get / player_state and applies settings patches, runs adb steps on the phone (the share intent), and walks playlists, the imports, the share and the settings on both the mock and the phone. ANDROID.md documents all of it.
591 lines
37 KiB
JavaScript
591 lines
37 KiB
JavaScript
// Thin wrapper over Tauri IPC so components never import tauri directly.
|
|
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
|
|
import { listen } from "./events.js";
|
|
import { mockInvoke } from "./mockLibrary.js";
|
|
|
|
// mockInvoke is inert (returns undefined) unless localStorage.mockLibrary === "1".
|
|
const invoke = (cmd, args) => mockInvoke(cmd, args) ?? tauriInvoke(cmd, args);
|
|
|
|
// Artist-page payloads are keyed by a Deezer id all the way through the
|
|
// backend. Reject a mismatched response at the IPC boundary so a provider,
|
|
// cache, or future command regression cannot populate another artist's page.
|
|
// String comparison deliberately accepts route-string ids and numeric Tauri
|
|
// ids as the same identity without coercing the u64 argument sent to Rust.
|
|
const artistIdKey = (value) => (value == null ? "" : String(value).trim());
|
|
const sameArtistId = (left, right) => {
|
|
const leftKey = artistIdKey(left);
|
|
return !!leftKey && leftKey === artistIdKey(right);
|
|
};
|
|
|
|
const checkedArtistPayload = (command, artistId) =>
|
|
invoke(command, { artistId }).then((payload) => {
|
|
if (!sameArtistId(payload?.artist?.id, artistId)) {
|
|
throw new Error(`${command} returned a mismatched artist identity`);
|
|
}
|
|
return payload;
|
|
});
|
|
|
|
const checkedArtistIdentity = (artistId, artist) =>
|
|
invoke("catalog_artist_identity", { artistId, artist }).then((payload) => {
|
|
if (!sameArtistId(payload?.artist_id ?? payload?.artistId, artistId)) {
|
|
throw new Error("catalog_artist_identity returned a mismatched artist identity");
|
|
}
|
|
return payload;
|
|
});
|
|
|
|
// The id is COERCED to a number here, not merely passed along. Deezer album
|
|
// ids reach the frontend as strings from two places — SQLite stores the
|
|
// recently-played anchor as TEXT, and page seeds carry `String(id)` — and the
|
|
// Rust command takes a u64, so a string argument is rejected outright with
|
|
// "invalid type: string … expected u64". That made every streamed row in the
|
|
// sidebar's history dead on click (2026-08-01). The identity check below still
|
|
// compares through `sameArtistId`, which normalizes both sides, so a numeric
|
|
// id and its string form remain the same album.
|
|
const checkedAlbumPayload = (albumId) => {
|
|
const numeric = typeof albumId === "number" ? albumId : Number(String(albumId).trim());
|
|
if (!Number.isFinite(numeric)) {
|
|
return Promise.reject(new Error(`catalog_album: not an album id: ${albumId}`));
|
|
}
|
|
return invoke("catalog_album", { albumId: numeric }).then((payload) => {
|
|
if (!sameArtistId(payload?.album?.id, albumId)) {
|
|
throw new Error("catalog_album returned a mismatched album identity");
|
|
}
|
|
return payload;
|
|
});
|
|
};
|
|
|
|
export const appStatus = () => invoke("app_status");
|
|
export const getCredentials = () => invoke("get_credentials");
|
|
export const connectAndLogin = (username, password, remember) =>
|
|
invoke("connect_and_login", { username, password, remember });
|
|
export const connectStored = () => invoke("connect_stored");
|
|
export const disconnect = () => invoke("disconnect");
|
|
export const startSearch = (query) => invoke("start_search", { query });
|
|
export const excludedSearchPhrases = () => invoke("excluded_search_phrases");
|
|
export const stopSearch = (token) => invoke("stop_search", { token });
|
|
export const downloadFile = (username, path, size) =>
|
|
invoke("download_file", { username, path, size });
|
|
export const copyStreamToFolder = (files, folder) =>
|
|
invoke("copy_stream_to_folder", { files, folder });
|
|
export const saveToLibrary = (files) => invoke("save_to_library", { files });
|
|
// Byte length of the cached copy of one peer file (null when uncached) —
|
|
// compared against the peer-stated size to tell "finished" from "prefix".
|
|
export const streamCacheLen = (username, path) =>
|
|
invoke("stream_cache_len", { username, path });
|
|
export const cancelDownload = (id) => invoke("cancel_download", { id });
|
|
export const showInFolder = (path) => invoke("show_in_folder", { path });
|
|
export const refreshArtistArt = () => invoke("refresh_artist_art");
|
|
export const playStream = (username, path, size, duration) =>
|
|
invoke("play_stream", { username, path, size, duration });
|
|
export const playLocal = (path, duration, startSecs = null) =>
|
|
invoke("play_local", { path, duration, startSecs });
|
|
export const preloadLocal = (path, duration) => invoke("preload_local", { path, duration });
|
|
export const playerControl = (action, value, rate) =>
|
|
invoke("player_control", { action, value: value ?? null, rate: rate ?? null });
|
|
// What the audio thread is doing right now. The webview only ever learns about
|
|
// playback from events, so after a reload this is the only way to find out that
|
|
// audio is still running — see `restoreSession`.
|
|
export const playerState = () => invoke("player_state");
|
|
// The mini player window (see mini/ and miniBridge.svelte.js).
|
|
export const miniOpen = (width, height) => invoke("mini_open", { width, height });
|
|
export const miniClose = () => invoke("mini_close");
|
|
export const waveform = (path, buckets, startSecs = null, endSecs = null) =>
|
|
invoke("waveform", { path, buckets, startSecs, endSecs });
|
|
export const libraryAlbums = () => invoke("library_albums");
|
|
export const libraryAlbumStats = (minSecs) => invoke("library_album_stats", { minSecs });
|
|
export const albumTracks = (albumId) => invoke("album_tracks", { albumId });
|
|
// `path` anchors the play to a library album via tracks.path (UNIQUE). Without
|
|
// it the row still records, but with no album — so it cannot appear in recents.
|
|
// `cat` carries catalog identity for a STREAMED play, which never touches the
|
|
// local library: { albumId, albumTitle, albumArtist, coverUrl }.
|
|
export const recordPlay = (artist, title, path, cat = {}) =>
|
|
invoke("record_play", {
|
|
artist,
|
|
title,
|
|
path: path ?? null,
|
|
catalogAlbumId: cat.albumId != null ? String(cat.albumId) : null,
|
|
albumTitle: cat.albumTitle ?? null,
|
|
albumArtist: cat.albumArtist ?? null,
|
|
coverUrl: cat.coverUrl ?? null,
|
|
});
|
|
// Destructive, not undoable: wipes `plays` only. Returns rows removed.
|
|
export const clearPlayHistory = () => invoke("clear_play_history");
|
|
export const playRowCount = () => invoke("play_row_count");
|
|
export const recentAlbums = (limit, minSecs) =>
|
|
invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null });
|
|
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
|
|
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
|
|
// Followed artists — the home redesign's future "new releases by artists you
|
|
// follow" reads the same list, which is why the rows carry name and portrait.
|
|
export const followedArtists = () => invoke("followed_artists");
|
|
export const followArtist = (artistId, name, portrait) =>
|
|
invoke("follow_artist", { artistId, name, portrait: portrait ?? null });
|
|
export const unfollowArtist = (artistId) => invoke("unfollow_artist", { artistId });
|
|
// Home landing page. `homeWarm` walks the follows through the same metadata
|
|
// entry points the pages use so every cache the feed reads is hot (measured
|
|
// 48s cold for 9 artists — the page itself must never pay that); `homeArtistNews`
|
|
// is one paced Google News query per followed artist, answered from a 6h cache.
|
|
export const homeWarm = () => invoke("home_warm");
|
|
export const homeArtistNews = (artists) => invoke("home_artist_news", { artists });
|
|
// The OS media card (Windows SMTC). Only the metadata crosses IPC and only on
|
|
// a track change — status and position are pushed by the backend's own player
|
|
// event pump, which already has both. See src-tauri/src/smtc.rs.
|
|
export const smtcMetadata = (title, artist, album, art) =>
|
|
invoke("smtc_metadata", {
|
|
title: title ?? "",
|
|
artist: artist ?? "",
|
|
album: album ?? "",
|
|
art: art ?? null,
|
|
});
|
|
// The WebView blocks target="_blank"; external links open via the backend.
|
|
export const openExternal = (url) => invoke("open_external", { url });
|
|
|
|
// ---- the Soulseek hub: rooms / chat / buddies / browse ----
|
|
export const slskJoinRoom = (room) => invoke("slsk_join_room", { room });
|
|
export const slskLeaveRoom = (room) => invoke("slsk_leave_room", { room });
|
|
export const slskSayRoom = (room, message) => invoke("slsk_say_room", { room, message });
|
|
export const slskMessageUser = (username, message) =>
|
|
invoke("slsk_message_user", { username, message });
|
|
export const slskRoomList = () => invoke("slsk_room_list");
|
|
export const slskBrowseUser = (username) => invoke("slsk_browse_user", { username });
|
|
export const slskUserInfo = (username) => invoke("slsk_user_info", { username });
|
|
export const slskUserInterests = (username) => invoke("slsk_user_interests", { username });
|
|
export const slskSimilarUsers = () => invoke("slsk_similar_users");
|
|
export const slskInterestsMine = () => invoke("slsk_interests_mine");
|
|
export const slskInterestSet = (thing, like, on) => invoke("slsk_interest_set", { thing, like, on });
|
|
export const slskLinkStatus = () => invoke("slsk_link_status");
|
|
export const slskLinkSelf = () => invoke("slsk_link_self");
|
|
export const slskLinkUnlink = () => invoke("slsk_link_unlink");
|
|
export const slskLinkCheck = (username) => invoke("slsk_link_check", { username });
|
|
export const slskLinkForget = (username) => invoke("slsk_link_forget", { username });
|
|
export const friendsList = () => invoke("friends_list");
|
|
export const friendRequest = (username) => invoke("friend_request", { username });
|
|
export const friendRequestByKey = (personaKey) => invoke("friend_request_by_key", { personaKey });
|
|
export const friendAccept = (id) => invoke("friend_accept", { id });
|
|
export const friendDecline = (id) => invoke("friend_decline", { id });
|
|
export const friendRemove = (id) => invoke("friend_remove", { id });
|
|
export const friendForget = (id) => invoke("friend_forget", { id });
|
|
export const sharedPlaylistsMine = () => invoke("shared_playlists_mine");
|
|
export const sharedPlaylistPublish = (playlistId) => invoke("shared_playlist_publish", { playlistId });
|
|
export const sharedPlaylistWithdraw = (playlistId) => invoke("shared_playlist_withdraw", { playlistId });
|
|
export const sharedPlaylistFetch = (owner, uuid) => invoke("shared_playlist_fetch", { owner, uuid });
|
|
export const sharedPlaylistImport = (owner, uuid) => invoke("shared_playlist_import", { owner, uuid });
|
|
export const shareSend = (username, playlistId) => invoke("share_send", { username, playlistId });
|
|
export const sharesInbox = () => invoke("shares_inbox");
|
|
export const shareDismiss = (id) => invoke("share_dismiss", { id });
|
|
export const sharedPlaylistsFollowing = () => invoke("shared_playlists_following");
|
|
export const sharedPlaylistFollow = (owner, uuid, playlistId) => invoke("shared_playlist_follow", { owner, uuid, playlistId });
|
|
export const sharedPlaylistUnfollow = (owner, uuid) => invoke("shared_playlist_unfollow", { owner, uuid });
|
|
export const sharedPlaylistsCheck = () => invoke("shared_playlists_check");
|
|
export const sharedPlaylistFollowUpdate = (owner, uuid) => invoke("shared_playlist_follow_update", { owner, uuid });
|
|
export const inboxStatus = () => invoke("inbox_status");
|
|
export const inboxPublish = () => invoke("inbox_publish");
|
|
export const inboxCheck = () => invoke("inbox_check");
|
|
export const spacesList = () => invoke("spaces_list");
|
|
export const spaceCreate = (name, description, isPrivate) => invoke("space_create", { name, description, private: isPrivate });
|
|
export const spaceOpen = (owner, space) => invoke("space_open", { owner, space });
|
|
export const spacePost = (owner, space, text) => invoke("space_post", { owner, space, text });
|
|
export const spaceInvite = (owner, space, personaKey) => invoke("space_invite", { owner, space, personaKey });
|
|
export const spaceRemoveMember = (owner, space, personaKey) => invoke("space_remove_member", { owner, space, personaKey });
|
|
export const spaceSetPinned = (owner, space, playlistOwner, playlistUuid) => invoke("space_set_pinned", { owner, space, playlistOwner, playlistUuid });
|
|
export const spaceLeave = (owner, space) => invoke("space_leave", { owner, space });
|
|
export const spaceSetShareListens = (owner, space, on) => invoke("space_set_share_listens", { owner, space, on });
|
|
export const spaceReportListen = (title, artist, album, durationSecs, trackId) =>
|
|
invoke("space_report_listen", { title, artist, album, durationSecs, trackId });
|
|
export const spaceBoard = (owner, space) => invoke("space_board", { owner, space });
|
|
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
|
|
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
|
|
export const overlapList = () => invoke("overlap_list");
|
|
export const holdingsSetShare = (owner, space, on) => invoke("holdings_set_share", { owner, space, on });
|
|
export const holdingsRoom = (owner, space, refresh = false) => invoke("holdings_room", { owner, space, refresh });
|
|
export const holdingsAsk = (items) => invoke("holdings_ask", { items });
|
|
export const holdingsFindFolder = (username, artist, album) => invoke("holdings_find_folder", { username, artist, album });
|
|
export const holdingsRepublish = () => invoke("holdings_republish");
|
|
export const holdingsWaits = () => invoke("holdings_waits");
|
|
export const holdingsWaitAdd = (username, artist, album, via = null) => invoke("holdings_wait_add", { username, artist, album, via });
|
|
export const holdingsWaitRemove = (username, artist, album) => invoke("holdings_wait_remove", { username, artist, album });
|
|
export const overlapCompute = (username) => invoke("overlap_compute", { username });
|
|
export const overlapForget = (username) => invoke("overlap_forget", { username });
|
|
export const buddyList = () => invoke("buddy_list");
|
|
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
|
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
|
// `maxEdge` asks the backend for a Lanczos3 thumbnail bounded to that many
|
|
// pixels on the long side. Omit it only where true pixels matter (the
|
|
// fullscreen viewer, the import picker's comparison and loupe).
|
|
export const albumArt = (path, maxEdge = null) => invoke("album_art", { path, maxEdge });
|
|
/** Warm the disk thumbnail cache for the whole library at the given edges —
|
|
* [{warm, decoded}] per edge. Slow by design (one decode at a time); callers
|
|
* fire-and-forget it. */
|
|
export const libraryArtPrewarm = (edges) => invoke("library_art_prewarm", { edges });
|
|
/** The cover embedded in a streamed track's own cache file, extracted to a
|
|
* local image path — null while the transfer is too young to read, or when
|
|
* the peer's file simply carries no picture. */
|
|
export const streamEmbeddedArt = (username, path) =>
|
|
invoke("stream_embedded_art", { username, path });
|
|
export const refreshAlbumArt = (albumId) => invoke("refresh_album_art", { albumId });
|
|
export const refreshAllAlbumArt = () => invoke("refresh_all_album_art");
|
|
export const getSettings = () => invoke("get_settings");
|
|
export const setSettings = (patch) => invoke("set_settings", patch);
|
|
// The in-app updater (updater.rs): status, a check on the current channel,
|
|
// settings (channel / autoCheck / endpointStable / endpointNightly), and the
|
|
// install — which on Windows ends this process when it succeeds.
|
|
export const updaterStatus = () => invoke("updater_status");
|
|
export const updaterCheck = () => invoke("updater_check");
|
|
export const updaterSet = (patch) => invoke("updater_set", patch);
|
|
export const updaterInstall = () => invoke("updater_install");
|
|
export const windowGlass = (on) => invoke("window_glass", { on });
|
|
export const forgetCredentials = () => invoke("forget_credentials");
|
|
export const pickFolder = () => invoke("pick_folder");
|
|
// ---- playlists (see src-tauri/src/playlists.rs) ---------------------------
|
|
// Rows are OCCURRENCES with their own ids: the same recording may appear
|
|
// twice, and every mutation addresses item ids, never positions.
|
|
export const listPlaylists = () => invoke("list_playlists");
|
|
export const createPlaylist = (name, kind = null) => invoke("create_playlist", { name, kind });
|
|
export const renamePlaylist = (id, name) => invoke("rename_playlist", { id, name });
|
|
export const setPlaylistDescription = (id, description) =>
|
|
invoke("set_playlist_description", { id, description: description ?? null });
|
|
export const setPlaylistPinned = (id, pinned) => invoke("set_playlist_pinned", { id, pinned });
|
|
export const duplicatePlaylist = (id, name) => invoke("duplicate_playlist", { id, name });
|
|
export const deletePlaylist = (id) => invoke("delete_playlist", { id });
|
|
export const playlistItems = (id) => invoke("playlist_items", { id });
|
|
export const playlistAddItems = (playlistId, items, at = null) =>
|
|
invoke("playlist_add_items", { playlistId, items, at });
|
|
export const playlistRemoveItems = (playlistId, itemIds) =>
|
|
invoke("playlist_remove_items", { playlistId, itemIds });
|
|
export const playlistSetOrder = (playlistId, orderedIds) =>
|
|
invoke("playlist_set_order", { playlistId, orderedIds });
|
|
// Repair one row: fields null = leave as stored; path non-null relinks the
|
|
// row to a playable file.
|
|
export const playlistRelinkItem = (playlistId, itemId, fields = {}) =>
|
|
invoke("playlist_relink_item", {
|
|
playlistId,
|
|
itemId,
|
|
title: fields.title ?? null,
|
|
artist: fields.artist ?? null,
|
|
album: fields.album ?? null,
|
|
durationSecs: fields.durationSecs ?? null,
|
|
path: fields.path ?? null,
|
|
artPath: fields.artPath ?? null,
|
|
trackId: fields.trackId ?? null,
|
|
artUrl: fields.artUrl ?? null,
|
|
});
|
|
// Cover as a data URL: custom master, else composed mosaic, else null.
|
|
export const playlistCover = (id, maxEdge = null) =>
|
|
invoke("playlist_cover", { id, maxEdge });
|
|
export const playlistCoverPick = (id) => invoke("playlist_cover_pick", { id });
|
|
export const playlistCoverClear = (id) => invoke("playlist_cover_clear", { id });
|
|
// Import: probe opens a file dialog and stages a reviewed draft; text stages
|
|
// a pasted tracklist; commit writes the accepted draft in one transaction.
|
|
export const playlistImportProbe = () => invoke("playlist_import_probe");
|
|
export const playlistImportFolder = () => invoke("playlist_import_folder");
|
|
export const playlistImportText = (text) => invoke("playlist_import_text", { text });
|
|
export const playlistImportYoutube = (url) => invoke("playlist_import_youtube", { url });
|
|
export const playlistImportYoutubePlaylist = (url) =>
|
|
invoke("playlist_import_youtube_playlist", { url });
|
|
// One door for every service link: YouTube, Spotify, Apple Music, TIDAL, Deezer.
|
|
export const playlistImportLink = (url) => invoke("playlist_import_link", { url });
|
|
// A pasted block of TRACK links (any supported service), looked up one page
|
|
// each — the way past Spotify's 100-track public ceiling.
|
|
export const playlistImportTrackLinks = (text, name = null) =>
|
|
invoke("playlist_import_track_links", { text, name });
|
|
// The set LISTENER (src-tauri/src/setlist_scan.rs + ytdlp.rs): recognize a
|
|
// mix's tracks from its audio. Progress rides "playlist:scan-progress".
|
|
export const playlistScanYoutube = (url) => invoke("playlist_scan_youtube", { url });
|
|
// The local door is split pick/scan so the modal can collect an optional
|
|
// pasted tracklist between the two.
|
|
export const playlistPickSetFile = () => invoke("playlist_pick_set_file");
|
|
export const playlistScanFile = (path, tracklist = null) =>
|
|
invoke("playlist_scan_file", { path, tracklist });
|
|
export const playlistScanCancel = () => invoke("playlist_scan_cancel");
|
|
export const ytdlpStatus = () => invoke("ytdlp_status");
|
|
export const ytdlpDownload = () => invoke("ytdlp_download");
|
|
export const denoDownload = () => invoke("deno_download");
|
|
export const playlistImportCommit = (name, description, kind, entries, coverUrl = null) =>
|
|
invoke("playlist_import_commit", {
|
|
name,
|
|
description: description ?? null,
|
|
kind,
|
|
entries,
|
|
coverUrl,
|
|
});
|
|
// Export via a save dialog; format ∈ m3u8 | xspf | csv | json. Resolves to
|
|
// the written path, or null when the dialog was cancelled.
|
|
export const playlistExport = (id, format) => invoke("playlist_export", { id, format });
|
|
|
|
// ---- lyric word-timing preparation (src-tauri/src/lyric_prep.rs) ----------
|
|
export const lyricModelStatus = () => invoke("lyric_model_status");
|
|
export const lyricModelDownload = () => invoke("lyric_model_download");
|
|
// Vocal separation model (src-tauri/src/vocal_sep.rs) — the ctc-v5 stem tier.
|
|
export const vocalSepStatus = () => invoke("vocal_sep_status");
|
|
export const vocalSepDownload = () => invoke("vocal_sep_download");
|
|
export const lyricPrepRequest = (path, durationSecs, lines, stream = null) =>
|
|
invoke("lyric_prep_request", {
|
|
path: path ?? null,
|
|
stream,
|
|
durationSecs: durationSecs ?? null,
|
|
lines,
|
|
});
|
|
// Local-library candidates for the unresolved-row repair panel.
|
|
export const libraryTrackSearch = (query) => invoke("library_track_search", { query });
|
|
// The whole index, for a list that filters on the device (the phone's Tracks tab).
|
|
export const libraryTrackIndex = () => invoke("library_track_index");
|
|
|
|
// ---- lyrics (see src-tauri/src/lyrics.rs) ----------------------------------
|
|
export const lyricsGet = (args) =>
|
|
invoke("lyrics_get", {
|
|
title: args.title,
|
|
artist: args.artist,
|
|
album: args.album ?? null,
|
|
durationSecs: args.durationSecs ?? null,
|
|
path: args.path ?? null,
|
|
refresh: args.refresh ?? null,
|
|
});
|
|
export const lyricsSearch = (title, artist) => invoke("lyrics_search", { title, artist });
|
|
export const lyricsSelect = (title, artist, durationSecs, candidateId) =>
|
|
invoke("lyrics_select", { title, artist, durationSecs: durationSecs ?? null, candidateId });
|
|
export const lyricsSetOffset = (title, artist, durationSecs, offsetMs) =>
|
|
invoke("lyrics_set_offset", { title, artist, durationSecs: durationSecs ?? null, offsetMs });
|
|
export const lyricsSaveText = (title, artist, durationSecs, text) =>
|
|
invoke("lyrics_save_text", { title, artist, durationSecs: durationSecs ?? null, text });
|
|
export const lyricsClear = (title, artist, durationSecs) =>
|
|
invoke("lyrics_clear", { title, artist, durationSecs: durationSecs ?? null });
|
|
export const lyricsSetAutoSync = (title, artist, durationSecs, enabled) =>
|
|
invoke("lyrics_set_auto_sync", { title, artist, durationSecs: durationSecs ?? null, enabled });
|
|
// Offline vocal-reduction render (band-limited center cancel — honest DSP,
|
|
// not a learned separator). Resolves to { path, cached } of a playable WAV.
|
|
export const lyricsPrepareInstrumental = (path) =>
|
|
invoke("lyrics_prepare_instrumental", { path });
|
|
// Vocal-band activity envelope for the sync editor ({ frameMs, frames }).
|
|
export const lyricsVocalActivity = (path) => invoke("lyrics_vocal_activity", { path });
|
|
// Persist hand-synced lines as the track's own document (wins the waterfall).
|
|
export const lyricsSaveLines = (title, artist, durationSecs, lines) =>
|
|
invoke("lyrics_save_lines", { title, artist, durationSecs: durationSecs ?? null, lines });
|
|
export const catalogSearch = (query) => invoke("catalog_search", { query });
|
|
// Track rows only — one Deezer call, and the query may use Deezer's field
|
|
// syntax (artist:"…" track:"…"). The identity lookups use this; the search
|
|
// PAGE still uses catalogSearch, which also wants artists and albums.
|
|
export const catalogSearchTracks = (query, limit = 10) =>
|
|
invoke("catalog_search_tracks", { query, limit });
|
|
// The artist page's whole shelf — Deezer plus the MusicBrainz/Apple
|
|
// supplements — flattened to tracks, for lookups the search index failed.
|
|
export const catalogArtistTrackCandidates = (artist, title) =>
|
|
invoke("catalog_artist_track_candidates", { artist, title });
|
|
export const catalogTrackByIsrc = (isrc) => invoke("catalog_track_by_isrc", { isrc });
|
|
export const rememberFusedArtistPortrait = (artistId, portrait) =>
|
|
invoke("remember_fused_artist_portrait", { artistId, portrait });
|
|
export const catalogArtist = (artistId) => checkedArtistPayload("catalog_artist", artistId);
|
|
export const catalogArtistArt = (artistId) =>
|
|
checkedArtistPayload("catalog_artist_art", artistId);
|
|
export const catalogArtistLogos = (artistId) => invoke("catalog_artist_logos", { artistId });
|
|
// Deferred MusicBrainz pass (socials + MB-only releases). Rate-limited at
|
|
// 1 req/s upstream, so it is fetched after the page renders, never before.
|
|
export const catalogArtistSocials = (artistId) =>
|
|
invoke("catalog_artist_socials", { artistId });
|
|
export const catalogArtistBio = (artistId, artist) =>
|
|
invoke("catalog_artist_bio", { artistId: artistId ?? null, artist });
|
|
export const catalogArtistIdentity = (artistId, artist) =>
|
|
checkedArtistIdentity(artistId, artist);
|
|
export const catalogCachedCoverLogo = (artistId) =>
|
|
invoke("catalog_cached_cover_logo", { artistId });
|
|
export const catalogCoverLogo = (artistId, artist, covers) =>
|
|
invoke("catalog_cover_logo", { artistId, artist, covers });
|
|
export const catalogArtistHeroSeed = (artistId) =>
|
|
invoke("catalog_artist_hero_seed", { artistId });
|
|
export const catalogArtistOverview = (artistId) =>
|
|
checkedArtistPayload("catalog_artist_overview", artistId);
|
|
// Popular's fast lane: the Last.fm-ordered rows with listener counts, waiting
|
|
// on nothing but the linked MBID and one Last.fm call. Patched over the
|
|
// overview's budgeted (cold: unranked) list; the full page still lands last.
|
|
export const catalogArtistPopular = (artistId) =>
|
|
invoke("catalog_artist_popular", { artistId });
|
|
export const pickArtistHero = (artistId) => invoke("pick_artist_hero", { artistId });
|
|
export const clearArtistHero = (artistId) => invoke("clear_artist_hero", { artistId });
|
|
export const catalogRelated = (artistId) => invoke("catalog_related", { artistId });
|
|
// Deferred Last.fm pass for search results: `tracks` is [{ artistId, title }]
|
|
// and the answer comes back in the same order. Kept OUT of catalog_search,
|
|
// which the typeahead calls per keystroke and which must never wait on
|
|
// MusicBrainz's 1 req/s gate.
|
|
export const catalogTrackListeners = (tracks) =>
|
|
invoke("catalog_track_listeners", { tracks });
|
|
// Genres + Last.fm totals for the search hero's artist. Deferred, like the rest.
|
|
export const catalogArtistProfile = (artistId) =>
|
|
invoke("catalog_artist_profile", { artistId });
|
|
export const catalogAlbum = (albumId) => checkedAlbumPayload(albumId);
|
|
export const catalogAlbumInfo = (artistId, artist, title) =>
|
|
invoke("catalog_album_info", { artistId: artistId ?? null, artist, title });
|
|
export const catalogArt = (url) => invoke("catalog_art", { url });
|
|
export const catalogCover = (artistId, artist, title, cover) =>
|
|
invoke("catalog_cover", { artistId: artistId ?? null, artist, title, cover });
|
|
export const invalidateCatalogCover = (artistId, artist, title, cover) =>
|
|
invoke("invalidate_catalog_cover", { artistId: artistId ?? null, artist, title, cover });
|
|
export const catalogHeroPreview = (url) => invoke("catalog_hero_preview", { url });
|
|
export const catalogHeroGpuSource = (url, plan = null) =>
|
|
invoke("catalog_hero_gpu_source", { url, plan });
|
|
export const catalogCachedHeroArt = (url) => invoke("catalog_cached_hero_art", { url });
|
|
export const catalogHeroArt = (url, plan = null) =>
|
|
invoke("catalog_hero_art", { url, plan });
|
|
export const preloadStream = (username, path, size) =>
|
|
invoke("preload_stream", { username, path, size });
|
|
export const preloadCachedStream = (username, path, size, duration) =>
|
|
invoke("preload_cached_stream", { username, path, size, duration });
|
|
export const streamWaveform = (username, path, size, buckets) =>
|
|
invoke("stream_waveform", { username, path, size, buckets });
|
|
export const streamCacheReady = (username, path, size) =>
|
|
invoke("stream_cache_ready", { username, path, size });
|
|
|
|
// Preview tracks (scrollable-track-previews): a fully separate audio path.
|
|
// previewResolve returns { ok, lease | failure, retryable } — the signed URL
|
|
// never crosses this boundary, only an opaque lease id does.
|
|
export const previewResolve = (trackId) => invoke("preview_resolve", { trackId });
|
|
// Fire-and-forget session prewarm: pulls every listed clip onto disk ahead
|
|
// of the user (paced, capped, cancelled by dispose). The invoke resolves
|
|
// when the walk finishes — callers void it.
|
|
export const previewPrewarm = (trackIds) => invoke("preview_prewarm", { trackIds });
|
|
export const previewLoad = (leaseId, startPaused, crossfadeMs = 0, startAtSecs = null) =>
|
|
invoke("preview_load", { leaseId, startPaused, crossfadeMs, startAtSecs });
|
|
export const playerFadePauseAcked = (fadeMs) =>
|
|
invoke("player_fade_pause_acked", { fadeMs });
|
|
export const playerFadeResume = (fadeMs) =>
|
|
invoke("player_fade_resume", { fadeMs });
|
|
export const debugStats = () => invoke("debug_stats");
|
|
|
|
// ---- the import tool (see src-tauri/src/import.rs) ------------------------
|
|
// Scan is read-only; apply COPIES into the managed root and registers — the
|
|
// source folder is never written to.
|
|
export const importScan = (root) => invoke("import_scan", { root });
|
|
export const importSessionGet = () => invoke("import_session_get");
|
|
// The source folder relative to the library folder, and back again. Saving
|
|
// both is what lets a review started on one machine resume on another after
|
|
// the drive lands at a different letter.
|
|
export const importPortableRoot = (root) => invoke("import_portable_root", { root });
|
|
export const importResolveRoot = (saved, rel) => invoke("import_resolve_root", { saved, rel });
|
|
export const importSessionSet = (state) => invoke("import_session_set", { state });
|
|
export const importSessionClear = () => invoke("import_session_clear");
|
|
export const importGetDest = () => invoke("import_get_dest");
|
|
export const importSetDest = (dir, layout = null) => invoke("import_set_dest", { dir, layout });
|
|
export const importApply = (sourceRoot, albums) => invoke("import_apply", { sourceRoot, albums });
|
|
// On-demand per-record identification (Deezer try-list → MB fallback) and
|
|
// the artwork panel's cover report. Both read-only.
|
|
// `title` is the record's own claim (tags); `folder` is the second witness,
|
|
// asked after it — a record whose tags are wrong still has its folder name.
|
|
// `shape` is everything ELSE the scan knows — track count, track titles, year,
|
|
// and where the files are so Rust can hash the record's own cover. That is what
|
|
// the discography pass compares when no phrase can find the record; the cover
|
|
// itself never crosses IPC (a master can be 8 MB).
|
|
export const importIdentify = (artist, title, folder = null, shape = {}) =>
|
|
invoke("import_identify", {
|
|
artist, title, folder,
|
|
tracks: shape.tracks ?? null,
|
|
trackTitles: shape.trackTitles ?? null,
|
|
year: shape.year ?? null,
|
|
sourceRoot: shape.sourceRoot ?? null,
|
|
rel: shape.rel ?? null,
|
|
// Bounds the cover hash to the record's own audio — a folded release
|
|
// gathers from these, an ordinary one from its folder alone.
|
|
parts: shape.parts ?? null,
|
|
});
|
|
export const importCoverReport = (artist, title, referenceUrl) =>
|
|
invoke("import_cover_report", { artist, title, referenceUrl });
|
|
// The record's own artwork, measured (embedded first, else folder images).
|
|
export const importLocalArt = (sourceRoot, rel, parts) =>
|
|
invoke("import_local_art", { sourceRoot, rel, parts });
|
|
// Prune rows whose files are gone, then index anything new — the real rescan.
|
|
export const rescanLibrary = () => invoke("rescan_library");
|
|
// The phone's storage plugin (crates/tauri-plugin-storage): a picked folder
|
|
// as { uri, path } (path null when the tree has none), and Android's
|
|
// all-files access — { granted }, and the system screen that grants it.
|
|
export const pickLibraryFolder = () => invoke("plugin:storage|pick_folder");
|
|
export const allFilesAccess = () => invoke("plugin:storage|all_files_access");
|
|
export const requestAllFilesAccess = () => invoke("plugin:storage|request_all_files_access");
|
|
// The phone's share plugin (crates/tauri-plugin-share): what another app
|
|
// shared to SoulStream, { text } — read once.
|
|
export const pendingShare = () => invoke("plugin:share|pending_share");
|
|
// 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".
|
|
export const importAudition = (username, rel, files, startIndex = 0) =>
|
|
invoke("import_audition", { username, rel, files, startIndex });
|
|
export const onImportAudition = (cb) => listen("import:audition", (e) => cb(e.payload));
|
|
// Which of a found copy's files the pen does not (fully) hold. Transfers die
|
|
// with the app and the arrived-count dies with them; the files on disk are
|
|
// what is left to ask.
|
|
export const importAuditionMissing = (rel, files) =>
|
|
invoke("import_audition_missing", { rel, files });
|
|
// Empty a record's pen when its peer is abandoned; returns the bytes freed.
|
|
// The pen is keyed by RECORD, so without this the dropped peer's files sit
|
|
// beside the replacement's until the session is cleared.
|
|
export const importAuditionClear = (rel) => invoke("import_audition_clear", { rel });
|
|
// Measured facts about one audio file (the version card's quality chips).
|
|
export const importProbeFile = (path) => invoke("import_probe_file", { path });
|
|
export const onImportScan = (cb) => listen("import:scan", (e) => cb(e.payload));
|
|
export const onImportScanFolder = (cb) => listen("import:scan-folder", (e) => cb(e.payload));
|
|
export const onImportApply = (cb) => listen("import:apply", (e) => cb(e.payload));
|
|
export const previewControl = (action, value = null) =>
|
|
invoke("preview_control", { action, value });
|
|
export const previewDispose = () => invoke("preview_dispose");
|
|
// Main-player silence acknowledgement (audio-focus contract). Throws on
|
|
// timeout — callers keep preview parked; they never assume silence.
|
|
export const playerPauseAcked = () => invoke("player_pause_acked");
|
|
|
|
// Window chrome (the app draws its own titlebar; decorations are off).
|
|
// Guarded like onEvent below: getCurrentWindow() THROWS outside Tauri, and
|
|
// it runs at mount via initWindowActivity — un-guarded it made every plain
|
|
// `vite` browser load a blank page, which also blocked browser-side preview
|
|
// fixtures. In-Tauri behavior is unchanged.
|
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
|
const inTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
const noWin = {
|
|
minimize: () => {},
|
|
toggleMaximize: () => {},
|
|
close: () => {},
|
|
isMinimized: async () => false,
|
|
isFocused: async () => true,
|
|
isMaximized: async () => false,
|
|
onResized: async () => () => {},
|
|
onFocusChanged: async () => () => {},
|
|
};
|
|
export const win = !inTauri
|
|
? noWin
|
|
: {
|
|
minimize: () => getCurrentWindow().minimize(),
|
|
toggleMaximize: () => getCurrentWindow().toggleMaximize(),
|
|
close: () => getCurrentWindow().close(),
|
|
isMinimized: () => getCurrentWindow().isMinimized(),
|
|
isFocused: () => getCurrentWindow().isFocused(),
|
|
isMaximized: () => getCurrentWindow().isMaximized(),
|
|
// returns Promise<unlisten>
|
|
onResized: (handler) => getCurrentWindow().onResized(handler),
|
|
onFocusChanged: (handler) => getCurrentWindow().onFocusChanged(handler),
|
|
};
|
|
|
|
// Subscribe to a backend event; returns an unlisten function.
|
|
// No-ops outside Tauri (e.g. plain-browser preview).
|
|
export function onEvent(name, handler) {
|
|
const un = listen(name, (e) => handler(e.payload)).catch(() => () => {});
|
|
return () => un.then((f) => f());
|
|
}
|
|
|
|
// ---- the Community area: Freenet layer, persona, profile, local contacts ----
|
|
export const freenetStatus = () => invoke("freenet_status");
|
|
export const freenetSetEnabled = (enabled) => invoke("freenet_set_enabled", { enabled });
|
|
export const freenetDetect = () => invoke("freenet_detect");
|
|
export const freenetSetLoadRemoteImages = (on) => invoke("freenet_set_load_remote_images", { on });
|
|
export const freenetNodeInfo = () => invoke("freenet_node_info");
|
|
export const freenetNodeStart = () => invoke("freenet_node_start");
|
|
export const freenetNodeStop = () => invoke("freenet_node_stop");
|
|
export const personaStatus = () => invoke("persona_status");
|
|
export const personaCreate = () => invoke("persona_create");
|
|
export const personaExport = (passphrase, path) => invoke("persona_export", { passphrase, path });
|
|
export const personaImport = (passphrase, path, force = false) =>
|
|
invoke("persona_import", { passphrase, path, force });
|
|
export const personaRotate = () => invoke("persona_rotate");
|
|
export const personaRemove = () => invoke("persona_remove");
|
|
export const profileGet = (persona = null, force = false) => invoke("profile_get", { persona, force });
|
|
export const profilePublish = (displayName, bio) => invoke("profile_publish", { displayName, bio });
|
|
export const profileSetAvatar = (imagePath) => invoke("profile_set_avatar", { imagePath });
|
|
export const profileClearAvatar = () => invoke("profile_clear_avatar");
|
|
export const contactsMetaList = () => invoke("contacts_meta_list");
|
|
export const contactMetaSet = (meta) => invoke("contact_meta_set", { meta });
|
|
/** kind: "image" | "recovery-open" | "recovery-save" -> a path, or null. */
|
|
export const communityPickFile = (kind) => invoke("community_pick_file", { kind });
|