forked from Keelrleek/SoulStream
community: one area for the people on both networks, and the hub grows search tabs, a folder column and a queue column
This commit is contained in:
1 parent
6dce4e2ac7
commit
8c25f93afe
14 files changed
+2367
-67
No files matched your search
@@ -857,6 +857,9 @@ pub async fn connect_and_login(
|
||||
"id": u.id,
|
||||
"username": u.username,
|
||||
"name": u.filename.rsplit('\\').next().unwrap_or(&u.filename),
|
||||
// Full shared path too — Transfers shows the
|
||||
// parent folder ahead of the name, Qt-style.
|
||||
"path": u.filename,
|
||||
"state": state,
|
||||
"sent": u.sent,
|
||||
"total": u.total,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! The Community area's own small backend: the local contact metadata
|
||||
//! (petnames, groups, notes, the two local marks) and the file dialogs the
|
||||
//! You section needs. Everything Soulseek lives in commands.rs, everything
|
||||
//! Freenet in freenet.rs / persona.rs / profile.rs — this is the glue that
|
||||
//! is neither.
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::config::{self, ContactMeta};
|
||||
|
||||
#[tauri::command]
|
||||
pub fn contacts_meta_list(app: tauri::AppHandle) -> Vec<ContactMeta> {
|
||||
config::load(&app).contacts
|
||||
}
|
||||
|
||||
/// Upsert one contact's local metadata. An entry with nothing set is
|
||||
/// dropped rather than stored empty, so the list only ever holds people the
|
||||
/// user actually said something about.
|
||||
#[tauri::command]
|
||||
pub fn contact_meta_set(app: tauri::AppHandle, meta: ContactMeta) -> Vec<ContactMeta> {
|
||||
let blank = |s: &Option<String>| s.as_deref().map_or(true, |v| v.trim().is_empty());
|
||||
let mut cfg = config::load(&app);
|
||||
cfg.contacts.retain(|c| c.username != meta.username);
|
||||
let empty = blank(&meta.petname)
|
||||
&& blank(&meta.group)
|
||||
&& blank(&meta.note)
|
||||
&& !meta.trusted
|
||||
&& !meta.favorite;
|
||||
if !empty {
|
||||
let trim = |s: Option<String>| s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty());
|
||||
cfg.contacts.push(ContactMeta {
|
||||
username: meta.username,
|
||||
petname: trim(meta.petname),
|
||||
group: trim(meta.group),
|
||||
note: trim(meta.note),
|
||||
trusted: meta.trusted,
|
||||
favorite: meta.favorite,
|
||||
});
|
||||
}
|
||||
config::save(&app, &cfg);
|
||||
cfg.contacts
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum PickKind {
|
||||
/// A source image for the avatar pipeline.
|
||||
Image,
|
||||
/// An existing recovery artifact to import.
|
||||
RecoveryOpen,
|
||||
/// Where to write a new recovery artifact.
|
||||
RecoverySave,
|
||||
}
|
||||
|
||||
const RECOVERY_EXT: &str = "ssrecovery";
|
||||
|
||||
/// Native file dialogs for the You section. Paths only — the bytes behind
|
||||
/// them are read and written by Rust (persona.rs, profile.rs), never by the
|
||||
/// webview.
|
||||
#[tauri::command]
|
||||
pub async fn community_pick_file(
|
||||
app: tauri::AppHandle,
|
||||
kind: PickKind,
|
||||
) -> Result<Option<String>, String> {
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
let (tx, rx) = tokio::sync::oneshot::channel();
|
||||
let done = move |p: Option<tauri_plugin_dialog::FilePath>| {
|
||||
let _ = tx.send(p.map(|p| p.to_string()));
|
||||
};
|
||||
match kind {
|
||||
PickKind::Image => app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("Image", &["jpg", "jpeg", "png", "webp"])
|
||||
.pick_file(done),
|
||||
PickKind::RecoveryOpen => app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("SoulStream recovery file", &[RECOVERY_EXT])
|
||||
.pick_file(done),
|
||||
PickKind::RecoverySave => app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("SoulStream recovery file", &[RECOVERY_EXT])
|
||||
.set_file_name(&format!("soulstream-identity.{RECOVERY_EXT}"))
|
||||
.save_file(done),
|
||||
}
|
||||
Ok(rx.await.unwrap_or(None))
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
mod commands;
|
||||
mod community;
|
||||
mod config;
|
||||
mod cover_logo;
|
||||
mod diagnostics;
|
||||
mod freenet;
|
||||
mod persona;
|
||||
mod hero_crop;
|
||||
mod import;
|
||||
mod hero_detector;
|
||||
@@ -11,6 +14,7 @@ mod lyric_prep;
|
||||
mod lyrics;
|
||||
mod pipeline;
|
||||
mod playback;
|
||||
mod profile;
|
||||
mod playlist_formats;
|
||||
mod playlists;
|
||||
mod provider_playlist;
|
||||
@@ -57,6 +61,10 @@ pub fn run() {
|
||||
preview::init(app.handle());
|
||||
loudness::init(app.handle());
|
||||
diagnostics::init(app.handle());
|
||||
// The experimental Freenet layer: applies the saved opt-in (off
|
||||
// by default) and forwards adapter status to the webview. Fully
|
||||
// isolated — its absence or failure touches nothing else.
|
||||
freenet::init(app.handle());
|
||||
if let Err(e) = pipeline::init(app.handle()) {
|
||||
log::error!("library init failed: {e}");
|
||||
}
|
||||
@@ -215,6 +223,26 @@ pub fn run() {
|
||||
commands::preload_cached_stream,
|
||||
commands::stream_waveform,
|
||||
commands::stream_cache_ready,
|
||||
freenet::freenet_status,
|
||||
freenet::freenet_set_enabled,
|
||||
freenet::freenet_detect,
|
||||
freenet::freenet_set_load_remote_images,
|
||||
freenet::freenet_node_info,
|
||||
freenet::freenet_node_start,
|
||||
freenet::freenet_node_stop,
|
||||
community::contacts_meta_list,
|
||||
community::contact_meta_set,
|
||||
community::community_pick_file,
|
||||
persona::persona_status,
|
||||
persona::persona_create,
|
||||
persona::persona_export,
|
||||
persona::persona_import,
|
||||
persona::persona_rotate,
|
||||
persona::persona_remove,
|
||||
profile::profile_get,
|
||||
profile::profile_publish,
|
||||
profile::profile_set_avatar,
|
||||
profile::profile_clear_avatar,
|
||||
preview::preview_resolve,
|
||||
preview::preview_prewarm,
|
||||
preview::preview_load,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
import { previewUi } from "./lib/preview/previewSession.svelte.js";
|
||||
import Home from "./views/Home.svelte";
|
||||
import Soulseek from "./views/Soulseek.svelte";
|
||||
import Community from "./views/Community.svelte";
|
||||
import Library from "./views/Library.svelte";
|
||||
import Settings from "./views/Settings.svelte";
|
||||
import Playlist from "./views/Playlist.svelte";
|
||||
@@ -50,6 +51,7 @@
|
||||
soulseek: Soulseek,
|
||||
search: Soulseek,
|
||||
downloads: Soulseek,
|
||||
community: Community,
|
||||
library: Library,
|
||||
settings: Settings,
|
||||
playlist: Playlist,
|
||||
@@ -89,6 +91,8 @@
|
||||
"soulseek",
|
||||
"search",
|
||||
"downloads",
|
||||
// Community is the hub's sibling: same "start BELOW the bar" shape.
|
||||
"community",
|
||||
// The Library follows the discography's exception: its blurred
|
||||
// ambience pins behind the glass and must begin at the very top of
|
||||
// the pane, so the page carries its own top inset instead of taking
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
// The Soulseek hub carries Qt's connection status in the bar's right —
|
||||
// "online as X · sharing N files" — the way the Qt titlebar did.
|
||||
const HUB_VIEWS = new Set(["soulseek", "search", "downloads"]);
|
||||
const HUB_VIEWS = new Set(["soulseek", "search", "downloads", "community"]);
|
||||
const showConn = $derived(HUB_VIEWS.has(ui.view));
|
||||
import { canGoBack, canGoForward, trail } from "../lib/navigation.svelte.js";
|
||||
import { markAppScroll, appScrollIdleFor } from "../lib/scrollbar.js";
|
||||
|
||||
@@ -38,6 +38,22 @@
|
||||
trash: '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>',
|
||||
expand: '<polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" x2="14" y1="3" y2="10"/><line x1="3" x2="10" y1="21" y2="14"/>',
|
||||
shrink: '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/><line x1="14" x2="21" y1="10" y2="3"/><line x1="3" x2="10" y1="21" y2="14"/>',
|
||||
// The Community area's glyphs (2026-09-02).
|
||||
hash: '<line x1="4" x2="20" y1="9" y2="9"/><line x1="4" x2="20" y1="15" y2="15"/><line x1="10" x2="8" y1="3" y2="21"/><line x1="16" x2="14" y1="3" y2="21"/>',
|
||||
inbox: '<polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/>',
|
||||
compass: '<circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/>',
|
||||
sparkle: '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/>',
|
||||
message: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
|
||||
send: '<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"/><path d="m21.854 2.147-10.94 10.939"/>',
|
||||
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
key: '<path d="m15.5 7.5 2.3 2.3a1 1 0 0 0 1.4 0l2.1-2.1a1 1 0 0 0 0-1.4L19 4"/><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/>',
|
||||
shield: '<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>',
|
||||
ban: '<circle cx="12" cy="12" r="10"/><path d="m4.9 4.9 14.2 14.2"/>',
|
||||
eye: '<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/>',
|
||||
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
|
||||
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
||||
more: '<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>',
|
||||
devices: '<rect width="20" height="14" x="2" y="3" rx="2"/><line x1="8" x2="16" y1="21" y2="21"/><line x1="12" x2="12" y1="17" y2="21"/>',
|
||||
"chevron-left": '<path d="m15 18-6-6 6-6"/>',
|
||||
"chevron-right": '<path d="m9 18 6-6-6-6"/>',
|
||||
"chevron-down": '<path d="m6 9 6 6 6-6"/>',
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
<script module>
|
||||
function h32(s) {
|
||||
let h = 0x811c9dc5;
|
||||
for (const c of String(s)) {
|
||||
h ^= c.charCodeAt(0);
|
||||
h = Math.imul(h, 0x01000193) >>> 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
function rng(seed) {
|
||||
let x = seed || 7;
|
||||
return () => {
|
||||
x ^= x << 13;
|
||||
x >>>= 0;
|
||||
x ^= x >>> 17;
|
||||
x ^= x << 5;
|
||||
x >>>= 0;
|
||||
return x / 4294967296;
|
||||
};
|
||||
}
|
||||
function arc(cx, cy, r, a0, a1) {
|
||||
const p = (a) => [cx + r * Math.cos((a * Math.PI) / 180), cy + r * Math.sin((a * Math.PI) / 180)];
|
||||
const [x0, y0] = p(a0);
|
||||
const [x1, y1] = p(a1);
|
||||
return `M${x0.toFixed(2)} ${y0.toFixed(2)}A${r} ${r} 0 ${a1 - a0 > 180 ? 1 : 0} 1 ${x1.toFixed(2)} ${y1.toFixed(2)}`;
|
||||
}
|
||||
/** The hue this seed paints with — the persona ambience borrows it. */
|
||||
export function hueOf(seed) {
|
||||
return h32(seed) % 360;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
// The SoulStream ID made visible: a record whose grooves are cut from
|
||||
// the key. Deterministic — the same seed draws the same face on every
|
||||
// machine, so a person is recognisable without ever uploading anything.
|
||||
// Approved on the Community mockup (research/community-mockups,
|
||||
// 2026-09-02, "Grooves"). A verified avatar photo replaces the grooves;
|
||||
// the presence dot is Soulseek's only — Freenet never says "online".
|
||||
let {
|
||||
seed = "",
|
||||
size = 40,
|
||||
/** A verified, sanitized avatar as a data URL — replaces the grooves. */
|
||||
photo = null,
|
||||
/** "online" | "away" | "off" | null (no dot at all). */
|
||||
presence = null,
|
||||
/** Accent ring — the identity strip's own face. */
|
||||
ring = false,
|
||||
/** Rounded square instead of a disc (Spaces, rooms). */
|
||||
square = false,
|
||||
} = $props();
|
||||
|
||||
const svg = $derived.by(() => {
|
||||
const r = rng(h32(seed));
|
||||
const hue = h32(seed) % 360;
|
||||
const hue2 = (hue + 150 + Math.floor(r() * 60)) % 360;
|
||||
const c1 = `hsl(${hue} 64% 60%)`;
|
||||
const c2 = `hsl(${hue2} 58% 64%)`;
|
||||
const bg = `hsl(${hue} 34% 13%)`;
|
||||
const bg2 = `hsl(${hue} 30% 20%)`;
|
||||
let inner = "";
|
||||
[11, 16, 21, 26].forEach((rad, i) => {
|
||||
const segs = 6 + i * 2;
|
||||
const gap = 8;
|
||||
for (let s = 0; s < segs; s++) {
|
||||
if (r() < 0.38) continue;
|
||||
const a0 = (s * 360) / segs + gap / 2;
|
||||
const a1 = ((s + 1) * 360) / segs - gap / 2;
|
||||
inner += `<path d="${arc(32, 32, rad, a0, a1)}" stroke="${r() > 0.72 ? c2 : c1}" stroke-width="3.6" fill="none" stroke-linecap="round"/>`;
|
||||
}
|
||||
});
|
||||
return `<svg viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" fill="${bg}"/><circle cx="32" cy="32" r="30.5" fill="${bg2}"/>${inner}<circle cx="32" cy="32" r="5.2" fill="${bg}"/><circle cx="32" cy="32" r="1.6" fill="${c1}"/></svg>`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<span class="av" class:ring class:square style="width:{size}px;height:{size}px">
|
||||
{#if photo}
|
||||
<img src={photo} alt="" draggable="false" />
|
||||
{:else}
|
||||
{@html svg}
|
||||
{/if}
|
||||
{#if presence}
|
||||
<span class="pd {presence}" style="--d:{Math.max(8, Math.round(size * 0.27))}px"></span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.av {
|
||||
position: relative;
|
||||
flex: none;
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.av.square {
|
||||
border-radius: 22%;
|
||||
}
|
||||
.av :global(svg),
|
||||
.av img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.av img {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.av.ring {
|
||||
box-shadow:
|
||||
0 0 0 2px var(--bg-base),
|
||||
0 0 0 3.5px var(--accent);
|
||||
}
|
||||
/* The dot sits on the pane, so its cut-out ring is the pane's own base
|
||||
colour; under glass that is a solid dark ring on the frost — read as a
|
||||
socket, which is fine. */
|
||||
.pd {
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
bottom: -1px;
|
||||
width: var(--d);
|
||||
height: var(--d);
|
||||
border-radius: 50%;
|
||||
background: #3ddc84;
|
||||
box-shadow: 0 0 0 2.5px var(--bg-base);
|
||||
}
|
||||
.pd.away {
|
||||
background: #e8a33d;
|
||||
}
|
||||
.pd.off {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
</style>
|
||||
@@ -191,6 +191,7 @@
|
||||
const items = [
|
||||
{ id: "home", label: "Home", icon: "home" },
|
||||
{ id: "soulseek", label: "Soulseek", icon: "globe" },
|
||||
{ id: "community", label: "Community", icon: "users" },
|
||||
{ id: "library", label: "Your Library", icon: "library" },
|
||||
{ id: "settings", label: "Settings", icon: "settings" },
|
||||
{ id: "playlists", label: "Playlists", icon: "playlist" },
|
||||
|
||||
@@ -488,3 +488,27 @@ 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 });
|
||||
@@ -0,0 +1,301 @@
|
||||
// The Community area's own store: the Freenet layer's status, the persona,
|
||||
// the published profile, the local contact metadata and the two device-local
|
||||
// memories (last seen, favourite rooms). Everything Soulseek-live — chats,
|
||||
// rooms, buddies — stays in state.svelte.js's `slsk` and is only READ here.
|
||||
//
|
||||
// Two words the labels below are careful about, from the design's truthful
|
||||
// list (FREENET-SUPPLEMENT-DESIGN §10.3): a successful fetch is "synced as of
|
||||
// <time>", never "online"; and the adapter's states are named for what they
|
||||
// are (node stopped, incompatible, connecting), never softened.
|
||||
import * as api from "./api.js";
|
||||
|
||||
const LS_SEEN = "community.lastSeen";
|
||||
const LS_FAVS = "community.roomFavs";
|
||||
const LS_SECTION = "community.section";
|
||||
|
||||
function readJson(key, fallback) {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(key) ?? "null");
|
||||
return v ?? fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
function writeJson(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch {
|
||||
// storage full or unavailable — a lost memory, never a failure
|
||||
}
|
||||
}
|
||||
|
||||
export const SECTIONS = ["inbox", "rooms", "people", "discover", "spaces", "you"];
|
||||
|
||||
export const community = $state({
|
||||
section: SECTIONS.includes(readJson(LS_SECTION, "")) ? readJson(LS_SECTION, "") : "inbox",
|
||||
/** freenet_status — null until first asked. */
|
||||
freenet: null,
|
||||
/** freenet_node_info — the installed peer, if any, and whether we run it. */
|
||||
nodeInfo: null,
|
||||
/** persona_status — null until first asked. */
|
||||
persona: null,
|
||||
/** profile_get for our own persona — null when none published. */
|
||||
profile: null,
|
||||
/** ms of the last successful profile read; what "synced as of" shows. */
|
||||
profileAt: null,
|
||||
profileError: "",
|
||||
/** username → ContactMeta (petname, group, note, trusted, favorite). */
|
||||
contacts: {},
|
||||
/** username → ms this device last saw them online or away. */
|
||||
lastSeen: readJson(LS_SEEN, {}),
|
||||
/** Favourite room names, device-local. */
|
||||
roomFavs: readJson(LS_FAVS, []),
|
||||
/** Which person the drawer shows, or null. */
|
||||
drawer: null,
|
||||
/** key → true while an action runs (buttons disable on it). */
|
||||
busy: {},
|
||||
/** The last action's error, shown where it happened. */
|
||||
error: "",
|
||||
});
|
||||
|
||||
export function setSection(s) {
|
||||
if (!SECTIONS.includes(s)) return;
|
||||
community.section = s;
|
||||
writeJson(LS_SECTION, s);
|
||||
}
|
||||
|
||||
/** The adapter's status on the wire: `{kind: "connected"}` — serde's
|
||||
* internally tagged, kebab-case form (detail-carrying variants add
|
||||
* `detail`). Older shapes (a bare string) are folded in for safety. */
|
||||
export function statusKind(status) {
|
||||
if (!status) return "";
|
||||
if (typeof status === "string") return status.toLowerCase();
|
||||
return String(status.kind ?? Object.keys(status)[0] ?? "").toLowerCase();
|
||||
}
|
||||
/** Human words for an AdapterStatus, from the design's truthful list. */
|
||||
export function adapterLabel(status) {
|
||||
switch (statusKind(status)) {
|
||||
case "disabled":
|
||||
return { text: "local only", tone: "off" };
|
||||
case "detecting":
|
||||
return { text: "connecting", tone: "acc" };
|
||||
case "nodeabsent":
|
||||
case "node-absent":
|
||||
return { text: "node not installed or stopped", tone: "warn" };
|
||||
case "nodeoccupied":
|
||||
case "node-occupied":
|
||||
return { text: "port in use by something else", tone: "warn" };
|
||||
case "nodeincompatible":
|
||||
case "node-incompatible":
|
||||
return { text: "incompatible node", tone: "err" };
|
||||
case "connected":
|
||||
return { text: "connected", tone: "ok" };
|
||||
case "lost":
|
||||
return { text: "connection lost · retrying", tone: "warn" };
|
||||
default:
|
||||
return { text: "unknown", tone: "off" };
|
||||
}
|
||||
}
|
||||
export const adapterConnected = (status) => statusKind(status) === "connected";
|
||||
|
||||
async function run(key, fn) {
|
||||
community.busy[key] = true;
|
||||
community.error = "";
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
community.error = String(e?.message ?? e);
|
||||
throw e;
|
||||
} finally {
|
||||
delete community.busy[key];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the Freenet layer ----------------------------------------------------
|
||||
export async function refreshFreenet() {
|
||||
try {
|
||||
community.freenet = await api.freenetStatus();
|
||||
} catch (e) {
|
||||
community.error = String(e);
|
||||
}
|
||||
await refreshNodeInfo();
|
||||
}
|
||||
export async function refreshNodeInfo() {
|
||||
try {
|
||||
community.nodeInfo = await api.freenetNodeInfo();
|
||||
} catch {
|
||||
// no node info is not an error state — the card shows what it has
|
||||
}
|
||||
}
|
||||
export const startNode = () =>
|
||||
run("node", async () => {
|
||||
community.nodeInfo = await api.freenetNodeStart();
|
||||
});
|
||||
export const stopNode = () =>
|
||||
run("node", async () => {
|
||||
community.nodeInfo = await api.freenetNodeStop();
|
||||
});
|
||||
export const setFreenetEnabled = (on) =>
|
||||
run("freenet", async () => {
|
||||
community.freenet = await api.freenetSetEnabled(on);
|
||||
if (!on) {
|
||||
community.persona = null;
|
||||
community.profile = null;
|
||||
community.profileAt = null;
|
||||
}
|
||||
});
|
||||
export const setLoadRemoteImages = (on) =>
|
||||
run("images", async () => {
|
||||
community.freenet = await api.freenetSetLoadRemoteImages(on);
|
||||
});
|
||||
export const detectNode = () =>
|
||||
run("detect", async () => {
|
||||
const status = await api.freenetDetect();
|
||||
if (community.freenet) community.freenet.status = status;
|
||||
return status;
|
||||
});
|
||||
|
||||
// ---- persona ----------------------------------------------------------------
|
||||
export async function refreshPersona() {
|
||||
try {
|
||||
community.persona = await api.personaStatus();
|
||||
} catch (e) {
|
||||
community.error = String(e);
|
||||
return;
|
||||
}
|
||||
if (community.persona?.persona) await loadProfile();
|
||||
else {
|
||||
community.profile = null;
|
||||
community.profileAt = null;
|
||||
}
|
||||
}
|
||||
export const personaKey = () => community.persona?.persona?.public_key ?? null;
|
||||
|
||||
export const createPersona = () =>
|
||||
run("persona", async () => {
|
||||
await api.personaCreate();
|
||||
await refreshPersona();
|
||||
});
|
||||
export const exportPersona = (passphrase) =>
|
||||
run("export", async () => {
|
||||
const path = await api.communityPickFile("recovery-save");
|
||||
if (!path) return null;
|
||||
const bytes = await api.personaExport(passphrase, path);
|
||||
return { path, bytes };
|
||||
});
|
||||
export const importPersona = (passphrase, force = false) =>
|
||||
run("import", async () => {
|
||||
const path = await api.communityPickFile("recovery-open");
|
||||
if (!path) return null;
|
||||
const info = await api.personaImport(passphrase, path, force);
|
||||
await refreshPersona();
|
||||
return info;
|
||||
});
|
||||
export const rotatePersona = () =>
|
||||
run("rotate", async () => {
|
||||
const info = await api.personaRotate();
|
||||
await refreshPersona();
|
||||
return info;
|
||||
});
|
||||
export const removePersona = () =>
|
||||
run("remove", async () => {
|
||||
await api.personaRemove();
|
||||
await refreshPersona();
|
||||
});
|
||||
|
||||
// ---- profile ----------------------------------------------------------------
|
||||
/** Our own profile: instant from the device's ledger; `force` asks the
|
||||
* network, which can take a minute when nothing is published. */
|
||||
export async function loadProfile(force = false) {
|
||||
community.profileError = "";
|
||||
community.busy.profile = true;
|
||||
try {
|
||||
community.profile = await api.profileGet(null, force);
|
||||
community.profileAt = Date.now();
|
||||
} catch (e) {
|
||||
community.profileError = String(e);
|
||||
} finally {
|
||||
delete community.busy.profile;
|
||||
}
|
||||
}
|
||||
export const publishProfile = (displayName, bio) =>
|
||||
run("publish", async () => {
|
||||
community.profile = await api.profilePublish(displayName, bio);
|
||||
community.profileAt = Date.now();
|
||||
});
|
||||
export const pickAndSetAvatar = () =>
|
||||
run("avatar", async () => {
|
||||
const path = await api.communityPickFile("image");
|
||||
if (!path) return false;
|
||||
community.profile = await api.profileSetAvatar(path);
|
||||
community.profileAt = Date.now();
|
||||
return true;
|
||||
});
|
||||
export const clearAvatar = () =>
|
||||
run("avatar", async () => {
|
||||
community.profile = await api.profileClearAvatar();
|
||||
community.profileAt = Date.now();
|
||||
});
|
||||
|
||||
// ---- local contact metadata --------------------------------------------------
|
||||
function indexContacts(list) {
|
||||
community.contacts = Object.fromEntries(list.map((c) => [c.username, c]));
|
||||
}
|
||||
export async function loadContacts() {
|
||||
try {
|
||||
indexContacts(await api.contactsMetaList());
|
||||
} catch (e) {
|
||||
community.error = String(e);
|
||||
}
|
||||
}
|
||||
export const metaOf = (username) =>
|
||||
community.contacts[username] ?? {
|
||||
username,
|
||||
petname: null,
|
||||
group: null,
|
||||
note: null,
|
||||
trusted: false,
|
||||
favorite: false,
|
||||
};
|
||||
export const setContactMeta = (meta) =>
|
||||
run("meta:" + meta.username, async () => {
|
||||
indexContacts(await api.contactMetaSet(meta));
|
||||
});
|
||||
|
||||
// ---- device-local memories ------------------------------------------------------
|
||||
let seenFlush = 0;
|
||||
export function noteSeen(username) {
|
||||
community.lastSeen[username] = Date.now();
|
||||
clearTimeout(seenFlush);
|
||||
seenFlush = setTimeout(() => writeJson(LS_SEEN, community.lastSeen), 1500);
|
||||
}
|
||||
export function toggleRoomFav(name) {
|
||||
const i = community.roomFavs.indexOf(name);
|
||||
if (i >= 0) community.roomFavs.splice(i, 1);
|
||||
else community.roomFavs.push(name);
|
||||
writeJson(LS_FAVS, community.roomFavs);
|
||||
}
|
||||
|
||||
// ---- boot -------------------------------------------------------------------------
|
||||
let booted = false;
|
||||
/** Idempotent: the first Community mount wires the listeners and pulls the
|
||||
* initial state; later mounts only refresh. */
|
||||
export async function initCommunity() {
|
||||
if (!booted) {
|
||||
booted = true;
|
||||
api.onEvent("freenet:status", (status) => {
|
||||
if (community.freenet) community.freenet.status = status;
|
||||
else void refreshFreenet();
|
||||
void refreshNodeInfo();
|
||||
// A fresh connection may carry a persona we have not read yet;
|
||||
// a lost one invalidates what we had.
|
||||
if (adapterConnected(status)) void refreshPersona();
|
||||
else if (community.persona) community.persona.available = false;
|
||||
});
|
||||
api.onEvent("soulseek:user-state", (p) => {
|
||||
if (p.status === 1 || p.status === 2) noteSeen(p.username);
|
||||
});
|
||||
}
|
||||
await Promise.all([refreshFreenet(), loadContacts()]);
|
||||
await refreshPersona();
|
||||
}
|
||||
@@ -55,6 +55,7 @@ function entryKey(e) {
|
||||
* back to — "Search" as a bare destination is a box you have already left. */
|
||||
const ROOTS = new Set([
|
||||
"home",
|
||||
"community",
|
||||
"search",
|
||||
"library",
|
||||
"downloads",
|
||||
|
||||
+175
-49
@@ -1722,6 +1722,7 @@ export function goToView(view, opts = {}) {
|
||||
const VIEW_LABELS = {
|
||||
home: "Home",
|
||||
soulseek: "Soulseek",
|
||||
community: "Community",
|
||||
search: "Search",
|
||||
library: "Your Library",
|
||||
downloads: "Downloads",
|
||||
@@ -1733,6 +1734,7 @@ const VIEW_LABELS = {
|
||||
const VIEW_GLYPHS = {
|
||||
home: "home",
|
||||
soulseek: "globe",
|
||||
community: "users",
|
||||
search: "search",
|
||||
library: "library",
|
||||
downloads: "download",
|
||||
@@ -5447,27 +5449,68 @@ export async function saveCatalogAlbum(album) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const search = $state({
|
||||
token: null,
|
||||
query: "",
|
||||
searching: false,
|
||||
responses: 0,
|
||||
// key: `${username}|${folder}` -> group summary (scalars only — the file
|
||||
// lists live outside reactive state, see searchFiles below).
|
||||
groups: {},
|
||||
/* --------------------------------------------------------------- searching
|
||||
* Qt keeps every search alive in its own tab, and so do we: `searchTabs` is
|
||||
* the strip, each entry a self-contained session (its own token, groups and
|
||||
* raw file map). `search` below is the ACTIVE session's face — the whole UI
|
||||
* reads through it, so a tab switch is a one-line assignment here rather than
|
||||
* a rewrite there. */
|
||||
export const SEARCH_TABS_MAX = 8;
|
||||
|
||||
export const searchTabs = $state({
|
||||
/** Newest last. Each: {id, token, query, searching, responses, groups} */
|
||||
list: [],
|
||||
activeId: null,
|
||||
/** What searchNetworkFor is about to look for, shown by the box in the
|
||||
* gap before the first tab exists (and if login interrupts). */
|
||||
seeded: "",
|
||||
});
|
||||
|
||||
// Raw per-group file lists. Deliberately NOT reactive: a broad search stores
|
||||
// up to 300 × 500 file objects, and wrapping each in a $state proxy is what
|
||||
// used to freeze the webview (GC storm at ~1GB+). Components re-render off
|
||||
// the group's reactive fileCount and read the list through filesForGroup().
|
||||
const searchFiles = new Map();
|
||||
let searchStopTimer = null;
|
||||
let searchRequest = 0;
|
||||
let searchSeq = 0;
|
||||
// Raw per-group file lists, per session. Deliberately NOT reactive: a broad
|
||||
// search stores up to 300 × 500 file objects, and wrapping each in a $state
|
||||
// proxy is what used to freeze the webview (GC storm at ~1GB+). Components
|
||||
// re-render off the group's reactive fileCount and read the list through
|
||||
// filesForGroup(). Keyed by session id so closing a tab frees the whole map.
|
||||
const searchFilesById = new Map();
|
||||
// Session id -> its 45s routing-close timer.
|
||||
const searchStopTimers = new Map();
|
||||
const EMPTY_GROUPS = Object.freeze({});
|
||||
|
||||
function trimHiddenSearchFiles() {
|
||||
function sessionById(id) {
|
||||
return searchTabs.list.find((s) => s.id === id) ?? null;
|
||||
}
|
||||
function activeSession() {
|
||||
return sessionById(searchTabs.activeId);
|
||||
}
|
||||
function filesOf(id) {
|
||||
let m = searchFilesById.get(id);
|
||||
if (!m) searchFilesById.set(id, (m = new Map()));
|
||||
return m;
|
||||
}
|
||||
|
||||
/** The active session, as the single object every existing reader expects. */
|
||||
export const search = {
|
||||
get token() { return activeSession()?.token ?? null; },
|
||||
set token(v) { const s = activeSession(); if (s) s.token = v; },
|
||||
get query() { return activeSession()?.query ?? searchTabs.seeded; },
|
||||
// Seed ONLY. searchNetworkFor assigns this before it calls runSearch, and
|
||||
// a write-through would rename the live tab — which runSearch would then
|
||||
// find by query and re-run in place, wiping the results the user was
|
||||
// looking at. A tab's query is its identity; only runSearch sets it.
|
||||
set query(v) { searchTabs.seeded = v; },
|
||||
get searching() { return activeSession()?.searching ?? false; },
|
||||
set searching(v) { const s = activeSession(); if (s) s.searching = v; },
|
||||
get responses() { return activeSession()?.responses ?? 0; },
|
||||
set responses(v) { const s = activeSession(); if (s) s.responses = v; },
|
||||
get groups() { return activeSession()?.groups ?? EMPTY_GROUPS; },
|
||||
set groups(v) { const s = activeSession(); if (s) s.groups = v; },
|
||||
};
|
||||
|
||||
function trimHiddenSearchFiles(session) {
|
||||
if (!session) return;
|
||||
const visibleKeys = new Set(
|
||||
Object.values(search.groups)
|
||||
Object.values(session.groups)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.hasFreeSlot - a.hasFreeSlot)
|
||||
@@ -5476,13 +5519,15 @@ function trimHiddenSearchFiles() {
|
||||
.slice(0, 120)
|
||||
.map((group) => group.key),
|
||||
);
|
||||
for (const key of searchFiles.keys()) {
|
||||
if (!visibleKeys.has(key)) searchFiles.delete(key);
|
||||
const files = filesOf(session.id);
|
||||
for (const key of files.keys()) {
|
||||
if (!visibleKeys.has(key)) files.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export function filesForGroup(key) {
|
||||
return searchFiles.get(key)?.files ?? [];
|
||||
// Read-only: never creates a map (a null activeId would leave one behind).
|
||||
return searchFilesById.get(searchTabs.activeId)?.get(key)?.files ?? [];
|
||||
}
|
||||
|
||||
// Mirrors crates/audio-formats: everything recognised as audio, playable or
|
||||
@@ -5530,6 +5575,9 @@ export const slsk = $state({
|
||||
buddies: [],
|
||||
/** username → { status?, avg_speed?, file_count?, dir_count?, exists? }. */
|
||||
buddyState: {},
|
||||
/** A username another area (Community) wants browsed in the hub's Browse
|
||||
* tab; the hub consumes and clears it on arrival. */
|
||||
browseRequest: null,
|
||||
});
|
||||
|
||||
const SLSK_FEED_MAX = 500;
|
||||
@@ -6243,20 +6291,25 @@ export function initEvents() {
|
||||
// The backend batches search responses (~150ms ticks) so a broad search
|
||||
// can't flood the IPC bridge or trigger a reactive flush per response.
|
||||
api.onEvent("soulseek:search-results", (batch) => {
|
||||
let matched = 0;
|
||||
for (const r of batch) {
|
||||
if (!search.searching || r.token !== search.token) continue;
|
||||
matched += 1;
|
||||
// Every tab is live at once; the token says whose results these
|
||||
// are. A background tab fills in exactly like the visible one.
|
||||
const sess = searchTabs.list.find(
|
||||
(s) => s.searching && s.token != null && s.token === r.token,
|
||||
);
|
||||
if (!sess) continue;
|
||||
sess.responses += 1;
|
||||
const searchFiles = filesOf(sess.id);
|
||||
for (const f of r.files) {
|
||||
const key = `${r.username}|${f.folder}`;
|
||||
let g = search.groups[key];
|
||||
let g = sess.groups[key];
|
||||
let raw = searchFiles.get(key);
|
||||
if (!g) {
|
||||
// Cap group count so a popular search can't flood the DOM.
|
||||
if (searchFiles.size >= 300) continue;
|
||||
raw = { files: [], exts: new Map() };
|
||||
searchFiles.set(key, raw);
|
||||
g = search.groups[key] = {
|
||||
g = sess.groups[key] = {
|
||||
key,
|
||||
username: r.username,
|
||||
folder: f.folder,
|
||||
@@ -6290,7 +6343,6 @@ export function initEvents() {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (matched > 0) search.responses += matched;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7315,39 +7367,111 @@ if (import.meta.env.DEV && typeof window !== "undefined") {
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSearch(query) {
|
||||
const request = ++searchRequest;
|
||||
clearTimeout(searchStopTimer);
|
||||
const previousToken = search.token;
|
||||
// Invalidate routing before awaiting the new backend token, otherwise
|
||||
// late batches from the previous search can populate the freshly-cleared
|
||||
// result set during this gap.
|
||||
search.token = null;
|
||||
if (previousToken != null) {
|
||||
api.stopSearch(previousToken).catch(() => {});
|
||||
/** Bring a tab forward. The whole Search UI reads through `search`, so this
|
||||
* assignment IS the switch. */
|
||||
export function selectSearchTab(id) {
|
||||
if (sessionById(id)) searchTabs.activeId = id;
|
||||
}
|
||||
|
||||
/** Close one tab: stop its search on the wire and free its file map. */
|
||||
export function closeSearchTab(id) {
|
||||
const i = searchTabs.list.findIndex((s) => s.id === id);
|
||||
if (i < 0) return;
|
||||
const [sess] = searchTabs.list.splice(i, 1);
|
||||
clearTimeout(searchStopTimers.get(id));
|
||||
searchStopTimers.delete(id);
|
||||
searchFilesById.delete(id);
|
||||
if (sess.token != null) api.stopSearch(sess.token).catch(() => {});
|
||||
if (searchTabs.activeId === id) {
|
||||
// Prefer the neighbour to the left — Qt's behaviour, and it keeps a
|
||||
// rapid close-close-close from jumping across the strip.
|
||||
const next = searchTabs.list[Math.max(0, i - 1)];
|
||||
searchTabs.activeId = next ? next.id : null;
|
||||
}
|
||||
search.query = query;
|
||||
search.groups = {};
|
||||
searchFiles.clear();
|
||||
search.responses = 0;
|
||||
search.searching = true;
|
||||
}
|
||||
|
||||
/** Retire the oldest tab that is no longer live, so the strip stays bounded
|
||||
* without ever killing a search that is still trickling in. */
|
||||
function evictOldestSearchTab() {
|
||||
if (searchTabs.list.length < SEARCH_TABS_MAX) return;
|
||||
const victim =
|
||||
searchTabs.list.find((s) => !s.searching && s.id !== searchTabs.activeId)
|
||||
?? searchTabs.list.find((s) => s.id !== searchTabs.activeId)
|
||||
?? searchTabs.list[0];
|
||||
if (victim) closeSearchTab(victim.id);
|
||||
}
|
||||
|
||||
export async function runSearch(query) {
|
||||
// Re-running a query that already owns a tab focuses it instead of
|
||||
// opening a duplicate — Qt does the same, and it keeps the strip honest.
|
||||
const existing = searchTabs.list.find((s) => s.query === query);
|
||||
if (existing) {
|
||||
searchTabs.activeId = existing.id;
|
||||
if (existing.searching || existing.token != null) return;
|
||||
// A closed-out tab re-runs in place, keeping its position.
|
||||
existing.groups = {};
|
||||
filesOf(existing.id).clear();
|
||||
existing.responses = 0;
|
||||
existing.searching = true;
|
||||
try {
|
||||
await armSearch(existing, query);
|
||||
} catch (err) {
|
||||
// Keep the tab — it still holds the previous run's results.
|
||||
existing.searching = false;
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
evictOldestSearchTab();
|
||||
searchTabs.list.push({
|
||||
id: ++searchSeq,
|
||||
token: null,
|
||||
query,
|
||||
searching: true,
|
||||
responses: 0,
|
||||
// key: `${username}|${folder}` -> group summary (scalars only — the
|
||||
// file lists live outside reactive state, see searchFilesById).
|
||||
groups: {},
|
||||
});
|
||||
// Read the entry back out: THIS is the reactive proxy the store holds, and
|
||||
// the one every later write (token, searching) has to land on.
|
||||
const sess = searchTabs.list[searchTabs.list.length - 1];
|
||||
searchTabs.activeId = sess.id;
|
||||
searchTabs.seeded = "";
|
||||
try {
|
||||
await armSearch(sess, query);
|
||||
} catch (err) {
|
||||
// The wire refused (offline, mid-reconnect). A brand-new tab holds
|
||||
// nothing worth keeping, and a tab stuck "live" forever is a lie.
|
||||
closeSearchTab(sess.id);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the wire for a token and hold this session's 45s routing window. */
|
||||
async function armSearch(sess, query) {
|
||||
const token = await api.startSearch(query);
|
||||
if (request !== searchRequest) {
|
||||
if (!sessionById(sess.id)) {
|
||||
// Closed while we awaited the token — nothing left to route into.
|
||||
api.stopSearch(token).catch(() => {});
|
||||
return;
|
||||
}
|
||||
search.token = token;
|
||||
sess.token = token;
|
||||
// Soulseek searches trickle in; stop routing after 45s.
|
||||
searchStopTimer = setTimeout(() => {
|
||||
if (search.token === token) {
|
||||
search.searching = false;
|
||||
clearTimeout(searchStopTimers.get(sess.id));
|
||||
searchStopTimers.set(
|
||||
sess.id,
|
||||
setTimeout(() => {
|
||||
searchStopTimers.delete(sess.id);
|
||||
if (sess.token !== token) return;
|
||||
sess.searching = false;
|
||||
// The UI exposes only the top 120 groups. Keep their full file
|
||||
// lists and discard invisible raw payloads after routing closes;
|
||||
// scalar summaries remain available for the result count.
|
||||
trimHiddenSearchFiles();
|
||||
trimHiddenSearchFiles(sess);
|
||||
api.stopSearch(token).catch(() => {});
|
||||
}
|
||||
}, 45_000);
|
||||
}, 45_000),
|
||||
);
|
||||
}
|
||||
|
||||
// Dev-only: expose the LIVE module instance for the CDP verification loop.
|
||||
@@ -7655,5 +7779,7 @@ if (import.meta.env.DEV) {
|
||||
autoConnect, refreshSourcePrefs, _sourceAudit, sourcePrefs,
|
||||
playlistStore, refreshPlaylists, goToView, peekNextIndices,
|
||||
slsk, search, runSearch, uploadToast, announceUpload,
|
||||
searchTabs, selectSearchTab, closeSearchTab, filesForGroup,
|
||||
downloads, network, sendDm, openChat,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+124
-17
@@ -17,6 +17,9 @@
|
||||
ui,
|
||||
app,
|
||||
search,
|
||||
searchTabs,
|
||||
selectSearchTab,
|
||||
closeSearchTab,
|
||||
runSearch,
|
||||
downloadFile,
|
||||
downloadFolder,
|
||||
@@ -91,10 +94,15 @@
|
||||
default: return { cls: "off", label: d.state + "…" };
|
||||
}
|
||||
}
|
||||
function splitPath(name) {
|
||||
// Qt shows "folder\file"; our items carry the bare name — the dir
|
||||
// column simply stays empty until items carry their remote folder.
|
||||
return { dir: "", file: name };
|
||||
function splitPath(path, name) {
|
||||
// Qt shows "folder\file". The remote path is backslash separated and
|
||||
// usually deep (@@share\Music\Artist\Album [FLAC]\05 track.flac); only
|
||||
// the immediate parent earns column width — the file name is what the
|
||||
// row is FOR, so the dir must never be allowed to ellipsize it away.
|
||||
// The full path stays reachable in the cell's title.
|
||||
const parts = String(path ?? "").split("\\").filter(Boolean);
|
||||
const file = name ?? parts[parts.length - 1] ?? "";
|
||||
return { dir: parts.length > 1 ? parts[parts.length - 2] + "\\" : "", file };
|
||||
}
|
||||
function clearComplete() {
|
||||
for (const d of dlFinished) delete downloads.items[d.id];
|
||||
@@ -171,6 +179,13 @@
|
||||
app.showLogin = true;
|
||||
}
|
||||
}
|
||||
/** Bring another live search forward. Folder expansions are per-tab —
|
||||
* they key off username|folder, which means nothing in the new tab. */
|
||||
function pickTab(id) {
|
||||
if (id === searchTabs.activeId) return;
|
||||
selectSearchTab(id);
|
||||
expanded = {};
|
||||
}
|
||||
|
||||
// ---- browse -------------------------------------------------------
|
||||
let browseName = $state("");
|
||||
@@ -215,6 +230,14 @@
|
||||
tab = "browse";
|
||||
void runBrowse();
|
||||
}
|
||||
// The Community area hands a username over through the store and
|
||||
// navigates here; consume it once, on arrival.
|
||||
$effect(() => {
|
||||
const u = slsk.browseRequest;
|
||||
if (!u) return;
|
||||
slsk.browseRequest = null;
|
||||
untrack(() => browseFrom(u));
|
||||
});
|
||||
/** The short tail of a backslashed remote folder, for display. */
|
||||
const dirTail = (name) => name.split("\\").filter(Boolean).slice(-2).join("\\");
|
||||
|
||||
@@ -290,6 +313,24 @@
|
||||
}
|
||||
}
|
||||
const buddyRows = $derived(slsk.buddies.map((u) => ({ u, st: slsk.buddyState[u] ?? {} })));
|
||||
/** Qt's Users "Queue" column: where WE stand in that user's upload line.
|
||||
* That is exactly what a queued download from them already reports, so
|
||||
* the best (lowest) place across our transfers with them is the answer —
|
||||
* no per-buddy PlaceInQueue poll needed. Offline users show nothing;
|
||||
* online with nothing queued is a genuine 0. */
|
||||
function queuePlace(username, st) {
|
||||
// Only online/away (1/2) can hold a line. Offline — or a status we
|
||||
// simply haven't been told yet — shows nothing rather than a
|
||||
// confident zero.
|
||||
if (st.status !== 1 && st.status !== 2) return "—";
|
||||
let best = null;
|
||||
for (const d of Object.values(downloads.items)) {
|
||||
if (d.username !== username || d.state !== "queued") continue;
|
||||
if (d.queue_place == null) continue;
|
||||
if (best == null || d.queue_place < best) best = d.queue_place;
|
||||
}
|
||||
return best ?? 0;
|
||||
}
|
||||
function statusChip(st) {
|
||||
if (st.exists === false) return { cls: "err", label: "unknown" };
|
||||
if (st.status === 2) return { cls: "ok", label: "online" };
|
||||
@@ -362,9 +403,12 @@
|
||||
</div>
|
||||
{#each dlItems as d (d.id)}
|
||||
{@const chip = stateChip(d)}
|
||||
{@const fp = splitPath(d.path, d.name)}
|
||||
<div class="tgrid rowh">
|
||||
<span class="u"><span class="avatar"></span>{d.username}</span>
|
||||
<span class="f" title={d.name}>{splitPath(d.name).file}</span>
|
||||
<span class="f" title={d.path || d.name}
|
||||
>{#if fp.dir}<span class="dir">{fp.dir}</span>{/if}{fp.file}</span
|
||||
>
|
||||
<span class="prog" class:done={d.state === "complete"}>
|
||||
<i style="width: {d.state === 'transferring' ? pct(d) : d.state === 'complete' ? 100 : 0}%"></i>
|
||||
</span>
|
||||
@@ -405,9 +449,12 @@
|
||||
<span>ETA</span><span>Speed</span><span>Size</span><span>Place</span>
|
||||
</div>
|
||||
{#each uploads as u (u.id)}
|
||||
{@const fp = splitPath(u.path, u.name)}
|
||||
<div class="tgrid rowh">
|
||||
<span class="u"><span class="avatar"></span>{u.username}</span>
|
||||
<span class="f" title={u.name}>{u.name}</span>
|
||||
<span class="f" title={u.path || u.name}
|
||||
>{#if fp.dir}<span class="dir">{fp.dir}</span>{/if}{fp.file}</span
|
||||
>
|
||||
<span class="prog"><i style="width: {u.total > 0 ? (u.sent / u.total) * 100 : 0}%"></i></span>
|
||||
<span class="chipwrap"><span class="chip {u.state === 'complete' ? 'ok' : u.state === 'failed' ? 'err' : 'up'}">{u.state === "transferring" ? "uploading" : u.state}</span></span>
|
||||
<span class="num">—</span><span class="num">—</span>
|
||||
@@ -444,7 +491,7 @@
|
||||
<button type="button" class="fchip" class:on={hideQueued} onclick={() => (hideQueued = !hideQueued)}>Hide queued</button>
|
||||
</form>
|
||||
|
||||
{#if search.token == null}
|
||||
{#if searchTabs.list.length === 0}
|
||||
<div class="empty">
|
||||
<p class="big">Search the Soulseek network</p>
|
||||
<p class="sub">
|
||||
@@ -454,12 +501,29 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Every search keeps its own tab and keeps filling in the
|
||||
background, Qt-style. The count on the ACTIVE tab is what the
|
||||
filters left on screen; a background tab shows its own total. -->
|
||||
<div class="stabs">
|
||||
<span class="stab on">
|
||||
{#if search.searching}<span class="live"></span>{/if}
|
||||
{search.query}
|
||||
<span class="scount">{filtered.length}</span>
|
||||
{#each searchTabs.list as s (s.id)}
|
||||
{@const on = s.id === searchTabs.activeId}
|
||||
<span class="stab" class:on>
|
||||
<button class="stabpick" onclick={() => pickTab(s.id)} title={s.query}>
|
||||
{#if s.searching}<span class="live"></span>{/if}
|
||||
<span class="stabq">{s.query}</span>
|
||||
<span class="scount">
|
||||
{on ? filtered.length : Object.keys(s.groups).length}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="stabx"
|
||||
title="Close this search"
|
||||
onclick={() => closeSearchTab(s.id)}
|
||||
>
|
||||
<Icon name="x" size={11} />
|
||||
</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="sgrid thead">
|
||||
<span>User / folder / file</span><span>Speed</span><span>Queue</span>
|
||||
@@ -732,7 +796,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="ugrid thead">
|
||||
<span>Buddy</span><span>Status</span><span>Shared</span><span>Speed</span><span></span>
|
||||
<span>Buddy</span><span>Status</span><span>Shared</span><span>Speed</span><span>Queue</span><span></span>
|
||||
</div>
|
||||
{#each buddyRows as row (row.u)}
|
||||
{@const chip = statusChip(row.st)}
|
||||
@@ -744,6 +808,7 @@
|
||||
<span><span class="chip {chip.cls}">{chip.label}</span></span>
|
||||
<span class="num">{row.st.file_count != null ? `${row.st.file_count.toLocaleString()} files` : "—"}</span>
|
||||
<span class="num">{row.st.avg_speed ? fmtSpeed(row.st.avg_speed) : "—"}</span>
|
||||
<span class="num">{queuePlace(row.u, row.st)}</span>
|
||||
<span class="uacts">
|
||||
<button class="toolbtn" onclick={() => browseFrom(row.u)}>Browse</button>
|
||||
<button class="toolbtn" onclick={() => chatFrom(row.u)}>Chat</button>
|
||||
@@ -945,6 +1010,9 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
/* Qt's "folder\file": the parent folder leads in muted tone, the file
|
||||
name carries the row. */
|
||||
.tgrid .f .dir { color: var(--text-muted); }
|
||||
.tgrid .chipwrap { justify-self: end; }
|
||||
.prog {
|
||||
position: relative;
|
||||
@@ -1016,19 +1084,56 @@
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.stabs { display: flex; gap: 4px; margin-bottom: 8px; }
|
||||
.stabs { display: flex; gap: 4px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
/* One pill per live search. Same on/off vocabulary as .subtab: the active
|
||||
tab wears the surface, the rest sit bare and quiet. */
|
||||
.stab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
padding: 0 4px 0 0;
|
||||
border-radius: 8px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 800;
|
||||
color: var(--text-secondary);
|
||||
max-width: 240px;
|
||||
}
|
||||
.stab.on {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-highlight);
|
||||
}
|
||||
.stabpick {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 7px 0 12px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stabq { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.stabx {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
flex: none;
|
||||
}
|
||||
.stabx:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
.stab .live {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -1415,7 +1520,9 @@
|
||||
gap: 0 10px;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
grid-template-columns: minmax(160px, 1fr) 90px 110px 90px 200px;
|
||||
/* Mock's columns exactly, with a wider action cell — we carry three
|
||||
buttons (Browse / Chat / remove) where the mock drew two. */
|
||||
grid-template-columns: minmax(160px, 1fr) 90px 110px 90px 90px 200px;
|
||||
}
|
||||
.ugrid > span:nth-child(n + 2) { text-align: right; }
|
||||
.ugrid .c1 {
|
||||
@@ -1466,7 +1573,7 @@
|
||||
background: var(--glass-well);
|
||||
box-shadow: var(--glass-ring);
|
||||
}
|
||||
:global(:root.glass) .stab,
|
||||
:global(:root.glass) .stab.on,
|
||||
:global(:root.glass) .chip.off,
|
||||
:global(:root.glass) .dlall.queued {
|
||||
background: var(--glass-surface);
|
||||
|
||||
Reference in new issue
Block a user