forked from Keelrleek/SoulStream
Kyle: "this is going to be a soulseek menu now — essentially the soulseek
app in our app." Approved against research/soulseek-search/hub2.html, a
one-to-one mock in the real frame, then built in three layers.
THE AREA. Search and Downloads leave the sidebar; one Soulseek entry
(globe) opens src/views/Soulseek.svelte, whose tab row is SoulseekQt's own
anatomy: Transfers, Search, Browse, Rooms, Chat, Users. The old view ids
stay routable — App.svelte aliases "search" and "downloads" to the hub and
the component syncs its tab off them — so the title bar's download ring,
"get on soulseek" hand-offs, history entries and restored sessions all
land on the right tab. The glass bar rides the hub with the connection
line on its right (online as X · sharing N files), the page takes Home's
below-the-bar inset, and the bar arrows join the chrome spec (16px glyphs)
alongside the sidebar toggle's disc. The replaced Search.svelte and
Downloads.svelte are deleted; Transfers carries their data forward in Qt's
own columns (User/File/Progress/Status/ETA/Speed/Size/Place, cancel and
play/reveal/add on the rows, clear-complete and open-folder in the
toolbar, live speed totals in the footer) and Search re-skins the peer
search as the Qt table: results regrouped user -> folder -> file,
slot/queue chips, format filter chips that actually filter, fold-out
files with stream-and-download on hover.
THE WIRE. soulseek-proto learns the rest of the protocol: rooms 13-17
(say/join/leave/user-joined/user-left), private messages 22/23, the
watch/status/stats family 5/6/7/36, the room list 64, and the peer-side
browse pair 4/5 with Directory::read to decode what our own responder
already encoded. Parsers are tolerant where the wire is baroque — roster
sections consume their own declared counts even when they disagree with
the name list, RoomList ignores the private-room tail, truncated stats
default instead of failing the frame. Codec tests ride along, including a
browse round-trip against our own encoder.
soulseek-client turns those into Events (RoomList/RoomJoined/RoomMessage/
PrivateMessage/UserWatched/...), auto-acks every private message the
moment it lands — unacked DMs are redelivered on every login — and grows
a spawn-shaped browse(): the returned future owns an Arc of the internals
so the caller drops the client lock before awaiting the 45s worst case.
THE APP. The event pump forwards everything as soulseek:* events; new
commands cover join/leave/say/message/room-list/browse plus a buddy list
persisted in config and re-watched on every login so status and stats
stream while connected. The frontend slsk store drains it all into the
four tabs: Rooms (joined + popular lists, live roster column, per-user
colored feed, say box — the server echoes our own lines so nothing is
appended locally), Chat (recency-sorted conversations, unread badges, DM
bubbles — our half appended locally since DMs are NOT echoed), Users
(add/remove buddies, status chips, one-click Browse/Chat cross-wiring),
and Browse (type a name, walk their tree, stream or download any file).
And the sharing side finally taps the shoulder: UploadToast announces the
first event of every new upload in the shared toast slot, folding a
same-user burst into one counting pill ("nikac is taking 12 files")
instead of twelve entrances.
Live-verified against the real network: the server handed 703 rooms after
login, and a browse of a live peer returned 3,796 directories / 34,773
files in 929ms with full paths, sizes and attributes. Join/say and DMs
are wired but deliberately unexercised — the first hello in a real room
is Kyle's to say, not a probe's.
491 lines
29 KiB
JavaScript
491 lines
29 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 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());
|
|
}
|