forked from Keelrleek/SoulStream
523 lines
31 KiB
JavaScript
523 lines
31 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 "@tauri-apps/api/event";
|
|
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 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 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);
|
|
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 });
|
|
|
|
// ---- 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");
|
|
// 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 });
|