forked from Keelrleek/SoulStream
community: shared playlists, a signed revision and a pointer on the network, a reference over soulseek, an import through the normal door
This commit is contained in:
1 parent
984738135a
commit
13c44f731a
11 files changed
+1166
-3
No files matched your search
@@ -17,6 +17,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
pub mod slsk_link;
|
pub mod slsk_link;
|
||||||
pub mod friend;
|
pub mod friend;
|
||||||
|
pub mod playlist;
|
||||||
|
|
||||||
pub const SCHEMA_VERSION: u32 = 1;
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
pub const SIGN_DOMAIN: &str = "soulstream.profile/v1";
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
//! Playlist publication — F3's exact shapes: an immutable, content-addressed
|
||||||
|
//! revision (the portable manifest: entry occurrences with recording ids and
|
||||||
|
//! display fallbacks, never a path) and an owner-signed pointer to the
|
||||||
|
//! current revision per (owner, playlist uuid). BYTE-COMPATIBILITY IS THE
|
||||||
|
//! CONTRACT: both shapes are what the bundled revision/pointer wasm validate
|
||||||
|
//! (built from research/freenet-f3, code hashes pinned by the app's tests).
|
||||||
|
//!
|
||||||
|
//! Signing: the delegate caps payloads, so what it signs is blake3(sig-body)
|
||||||
|
//! under `soulstream.playlist/v1` — the F3 convention, mirrored here.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub const SIGN_DOMAIN: &str = "soulstream.playlist/v1";
|
||||||
|
pub const SCHEMA_VERSION: u32 = 1;
|
||||||
|
pub const REV_PARAMS_FAMILY: &[u8] = b"soulstream.playlist-rev.v1";
|
||||||
|
pub const PTR_PARAMS_FAMILY: &[u8] = b"soulstream.playlist-ptr.v1";
|
||||||
|
pub const MAX_MANIFEST_BYTES: usize = 2 * 1024 * 1024;
|
||||||
|
pub const MAX_ENTRIES: usize = 5000;
|
||||||
|
pub const MAX_NAME_BYTES: usize = 128;
|
||||||
|
pub const MAX_DESC_BYTES: usize = 2048;
|
||||||
|
pub const MAX_NOTE_BYTES: usize = 280;
|
||||||
|
pub const MAX_FIELD_BYTES: usize = 512;
|
||||||
|
pub const MAX_POINTER_BYTES: usize = 1024;
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ manifest
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RecordingRef {
|
||||||
|
pub musicbrainz_recording_id: Option<String>,
|
||||||
|
pub isrcs: Vec<String>,
|
||||||
|
pub deezer_id: Option<String>,
|
||||||
|
pub apple_music_id: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub artists: Vec<String>,
|
||||||
|
pub album: String,
|
||||||
|
pub duration_ms: Option<u32>,
|
||||||
|
pub release_year: Option<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct EntryV1 {
|
||||||
|
/// Occurrence id — duplicates of the same recording stay distinct.
|
||||||
|
pub entry_id: [u8; 16],
|
||||||
|
pub position: u32,
|
||||||
|
pub recording: RecordingRef,
|
||||||
|
pub note: Option<String>,
|
||||||
|
/// Free-text resolution hint ("artist title"), NEVER a remote path.
|
||||||
|
pub slsk_hint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bounded artwork REFERENCE — hash + type + size only, no bytes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ArtworkRef {
|
||||||
|
pub content_hash: [u8; 32],
|
||||||
|
pub media_type: String,
|
||||||
|
pub byte_length: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct ManifestV1 {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub owner: [u8; 32],
|
||||||
|
pub playlist_uuid: [u8; 16],
|
||||||
|
pub revision_seq: u64,
|
||||||
|
/// Previous revision's content hash, None for the first.
|
||||||
|
pub prev_revision_hash: Option<[u8; 32]>,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub artwork: Option<ArtworkRef>,
|
||||||
|
pub entries: Vec<EntryV1>,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ManifestSigBody<'a> {
|
||||||
|
schema_version: u32,
|
||||||
|
owner: &'a [u8; 32],
|
||||||
|
playlist_uuid: &'a [u8; 16],
|
||||||
|
revision_seq: u64,
|
||||||
|
prev_revision_hash: &'a Option<[u8; 32]>,
|
||||||
|
name: &'a str,
|
||||||
|
description: &'a str,
|
||||||
|
artwork: &'a Option<ArtworkRef>,
|
||||||
|
entries: &'a [EntryV1],
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The 32 bytes the delegate signs: blake3 of the serialized sig body.
|
||||||
|
pub fn manifest_sign_payload(m: &ManifestV1) -> Vec<u8> {
|
||||||
|
let body = bincode::serialize(&ManifestSigBody {
|
||||||
|
schema_version: m.schema_version,
|
||||||
|
owner: &m.owner,
|
||||||
|
playlist_uuid: &m.playlist_uuid,
|
||||||
|
revision_seq: m.revision_seq,
|
||||||
|
prev_revision_hash: &m.prev_revision_hash,
|
||||||
|
name: &m.name,
|
||||||
|
description: &m.description,
|
||||||
|
artwork: &m.artwork,
|
||||||
|
entries: &m.entries,
|
||||||
|
})
|
||||||
|
.expect("sig body serializes");
|
||||||
|
blake3::hash(&body).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify(owner: &[u8; 32], payload: &[u8], sig: &[u8]) -> bool {
|
||||||
|
crate::verify_sig_in(SIGN_DOMAIN, owner, payload, sig)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Invalid {
|
||||||
|
Oversized(usize),
|
||||||
|
HashMismatch,
|
||||||
|
Malformed,
|
||||||
|
WrongSchema(u32),
|
||||||
|
WrongOwner,
|
||||||
|
TooManyEntries(usize),
|
||||||
|
FieldTooLong(&'static str, usize),
|
||||||
|
BadSignature,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl core::fmt::Display for Invalid {
|
||||||
|
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Invalid::Oversized(n) => write!(f, "{n} bytes is over the cap"),
|
||||||
|
Invalid::HashMismatch => write!(f, "bytes do not hash to their address"),
|
||||||
|
Invalid::Malformed => write!(f, "bytes do not parse"),
|
||||||
|
Invalid::WrongSchema(v) => write!(f, "unknown schema {v}"),
|
||||||
|
Invalid::WrongOwner => write!(f, "owner differs from the address"),
|
||||||
|
Invalid::TooManyEntries(n) => write!(f, "{n} entries is over the cap"),
|
||||||
|
Invalid::FieldTooLong(w, n) => write!(f, "{w} is {n} bytes, over its cap"),
|
||||||
|
Invalid::BadSignature => write!(f, "owner signature does not verify"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_rev_params(owner: &[u8; 32], content_hash: &[u8; 32]) -> Vec<u8> {
|
||||||
|
let mut p = Vec::with_capacity(REV_PARAMS_FAMILY.len() + 64);
|
||||||
|
p.extend_from_slice(REV_PARAMS_FAMILY);
|
||||||
|
p.extend_from_slice(owner);
|
||||||
|
p.extend_from_slice(content_hash);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strict verification in the plan's order: size → content hash → parse →
|
||||||
|
/// schema → owner → caps → signature. All BEFORE any preview use.
|
||||||
|
pub fn validate_revision(
|
||||||
|
owner: &[u8; 32],
|
||||||
|
content_hash: &[u8; 32],
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<ManifestV1, Invalid> {
|
||||||
|
if bytes.len() > MAX_MANIFEST_BYTES {
|
||||||
|
return Err(Invalid::Oversized(bytes.len()));
|
||||||
|
}
|
||||||
|
if blake3::hash(bytes).as_bytes() != content_hash {
|
||||||
|
return Err(Invalid::HashMismatch);
|
||||||
|
}
|
||||||
|
let m: ManifestV1 = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?;
|
||||||
|
if m.schema_version != SCHEMA_VERSION {
|
||||||
|
return Err(Invalid::WrongSchema(m.schema_version));
|
||||||
|
}
|
||||||
|
if &m.owner != owner {
|
||||||
|
return Err(Invalid::WrongOwner);
|
||||||
|
}
|
||||||
|
if m.entries.len() > MAX_ENTRIES {
|
||||||
|
return Err(Invalid::TooManyEntries(m.entries.len()));
|
||||||
|
}
|
||||||
|
if m.name.len() > MAX_NAME_BYTES {
|
||||||
|
return Err(Invalid::FieldTooLong("name", m.name.len()));
|
||||||
|
}
|
||||||
|
if m.description.len() > MAX_DESC_BYTES {
|
||||||
|
return Err(Invalid::FieldTooLong("description", m.description.len()));
|
||||||
|
}
|
||||||
|
for e in &m.entries {
|
||||||
|
if e.note.as_deref().map(str::len).unwrap_or(0) > MAX_NOTE_BYTES {
|
||||||
|
return Err(Invalid::FieldTooLong("note", e.note.as_ref().unwrap().len()));
|
||||||
|
}
|
||||||
|
for s in [&e.recording.title, &e.recording.album] {
|
||||||
|
if s.len() > MAX_FIELD_BYTES {
|
||||||
|
return Err(Invalid::FieldTooLong("recording field", s.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.slsk_hint.as_deref().map(str::len).unwrap_or(0) > MAX_FIELD_BYTES {
|
||||||
|
return Err(Invalid::FieldTooLong("slsk_hint", e.slsk_hint.as_ref().unwrap().len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !verify(owner, &manifest_sign_payload(&m), &m.sig) {
|
||||||
|
return Err(Invalid::BadSignature);
|
||||||
|
}
|
||||||
|
Ok(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ pointer
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RevisionRef {
|
||||||
|
pub content_hash: [u8; 32],
|
||||||
|
pub revision_seq: u64,
|
||||||
|
pub entry_count: u32,
|
||||||
|
pub byte_length: u32,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct PointerState {
|
||||||
|
pub schema_version: u32,
|
||||||
|
pub owner: [u8; 32],
|
||||||
|
pub playlist_uuid: [u8; 16],
|
||||||
|
pub sequence: u64,
|
||||||
|
/// None + withdrawn = "removed from current state" — old revisions
|
||||||
|
/// remain fetchable; never claim network erasure.
|
||||||
|
pub current: Option<RevisionRef>,
|
||||||
|
pub withdrawn: bool,
|
||||||
|
pub sig: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PointerSigBody<'a> {
|
||||||
|
schema_version: u32,
|
||||||
|
owner: &'a [u8; 32],
|
||||||
|
playlist_uuid: &'a [u8; 16],
|
||||||
|
sequence: u64,
|
||||||
|
current: &'a Option<RevisionRef>,
|
||||||
|
withdrawn: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pointer_sign_payload(p: &PointerState) -> Vec<u8> {
|
||||||
|
let body = bincode::serialize(&PointerSigBody {
|
||||||
|
schema_version: p.schema_version,
|
||||||
|
owner: &p.owner,
|
||||||
|
playlist_uuid: &p.playlist_uuid,
|
||||||
|
sequence: p.sequence,
|
||||||
|
current: &p.current,
|
||||||
|
withdrawn: p.withdrawn,
|
||||||
|
})
|
||||||
|
.expect("sig body serializes");
|
||||||
|
blake3::hash(&body).as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn make_ptr_params(owner: &[u8; 32], playlist_uuid: &[u8; 16]) -> Vec<u8> {
|
||||||
|
let mut p = Vec::with_capacity(PTR_PARAMS_FAMILY.len() + 48);
|
||||||
|
p.extend_from_slice(PTR_PARAMS_FAMILY);
|
||||||
|
p.extend_from_slice(owner);
|
||||||
|
p.extend_from_slice(playlist_uuid);
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_pointer(
|
||||||
|
owner: &[u8; 32],
|
||||||
|
uuid: &[u8; 16],
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<PointerState, Invalid> {
|
||||||
|
if bytes.len() > MAX_POINTER_BYTES {
|
||||||
|
return Err(Invalid::Oversized(bytes.len()));
|
||||||
|
}
|
||||||
|
let p: PointerState = bincode::deserialize(bytes).map_err(|_| Invalid::Malformed)?;
|
||||||
|
if p.schema_version != SCHEMA_VERSION {
|
||||||
|
return Err(Invalid::WrongSchema(p.schema_version));
|
||||||
|
}
|
||||||
|
if &p.owner != owner || &p.playlist_uuid != uuid {
|
||||||
|
return Err(Invalid::WrongOwner);
|
||||||
|
}
|
||||||
|
if let Some(r) = &p.current {
|
||||||
|
if r.name.len() > MAX_NAME_BYTES {
|
||||||
|
return Err(Invalid::FieldTooLong("pointer name", r.name.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !verify(owner, &pointer_sign_payload(&p), &p.sig) {
|
||||||
|
return Err(Invalid::BadSignature);
|
||||||
|
}
|
||||||
|
Ok(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
|
||||||
|
fn sign(key: &SigningKey, payload: &[u8]) -> Vec<u8> {
|
||||||
|
key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_revision_and_its_pointer_round_trip_and_refuse_tampering() {
|
||||||
|
let key = SigningKey::from_bytes(&[5u8; 32]);
|
||||||
|
let owner = key.verifying_key().to_bytes();
|
||||||
|
let mut m = ManifestV1 {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
owner,
|
||||||
|
playlist_uuid: [9; 16],
|
||||||
|
revision_seq: 1,
|
||||||
|
prev_revision_hash: None,
|
||||||
|
name: "Rainy Window".into(),
|
||||||
|
description: "long fades".into(),
|
||||||
|
artwork: None,
|
||||||
|
entries: vec![EntryV1 {
|
||||||
|
entry_id: [1; 16],
|
||||||
|
position: 0,
|
||||||
|
recording: RecordingRef {
|
||||||
|
musicbrainz_recording_id: None,
|
||||||
|
isrcs: vec![],
|
||||||
|
deezer_id: None,
|
||||||
|
apple_music_id: None,
|
||||||
|
title: "Airbag".into(),
|
||||||
|
artists: vec!["Radiohead".into()],
|
||||||
|
album: "OK Computer".into(),
|
||||||
|
duration_ms: Some(284_000),
|
||||||
|
release_year: Some(1997),
|
||||||
|
},
|
||||||
|
note: None,
|
||||||
|
slsk_hint: Some("radiohead airbag".into()),
|
||||||
|
}],
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
m.sig = sign(&key, &manifest_sign_payload(&m));
|
||||||
|
let bytes = bincode::serialize(&m).unwrap();
|
||||||
|
let hash = *blake3::hash(&bytes).as_bytes();
|
||||||
|
assert_eq!(validate_revision(&owner, &hash, &bytes).unwrap(), m);
|
||||||
|
// content addressing: a flipped byte trips the hash BEFORE parse
|
||||||
|
let mut flipped = bytes.clone();
|
||||||
|
flipped[10] ^= 1;
|
||||||
|
assert_eq!(validate_revision(&owner, &hash, &flipped), Err(Invalid::HashMismatch));
|
||||||
|
// a re-signed edit by the wrong key is a bad signature
|
||||||
|
let mut edited = m.clone();
|
||||||
|
edited.name = "Sunny Window".into();
|
||||||
|
edited.sig = sign(&SigningKey::from_bytes(&[6u8; 32]), &manifest_sign_payload(&edited));
|
||||||
|
let eb = bincode::serialize(&edited).unwrap();
|
||||||
|
assert_eq!(validate_revision(&owner, blake3::hash(&eb).as_bytes(), &eb), Err(Invalid::BadSignature));
|
||||||
|
|
||||||
|
let mut p = PointerState {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
owner,
|
||||||
|
playlist_uuid: [9; 16],
|
||||||
|
sequence: 1,
|
||||||
|
current: Some(RevisionRef { content_hash: hash, revision_seq: 1, entry_count: 1, byte_length: bytes.len() as u32, name: "Rainy Window".into() }),
|
||||||
|
withdrawn: false,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
p.sig = sign(&key, &pointer_sign_payload(&p));
|
||||||
|
let pb = bincode::serialize(&p).unwrap();
|
||||||
|
assert_eq!(validate_pointer(&owner, &[9; 16], &pb).unwrap(), p);
|
||||||
|
assert_eq!(validate_pointer(&owner, &[8; 16], &pb), Err(Invalid::WrongOwner));
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
@@ -930,6 +930,7 @@ pub async fn connect_and_login(
|
|||||||
// never reach the Inbox (slsk_link.rs).
|
// never reach the Inbox (slsk_link.rs).
|
||||||
if crate::slsk_link::intercept_pm(&event_app, &username, &message).await
|
if crate::slsk_link::intercept_pm(&event_app, &username, &message).await
|
||||||
|| crate::friends::intercept_pm(&event_app, &username, &message)
|
|| crate::friends::intercept_pm(&event_app, &username, &message)
|
||||||
|
|| crate::shared_playlists::intercept_pm(&event_app, &username, &message)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ mod playlists;
|
|||||||
mod provider_playlist;
|
mod provider_playlist;
|
||||||
mod preview;
|
mod preview;
|
||||||
mod setlist_scan;
|
mod setlist_scan;
|
||||||
|
mod shared_playlists;
|
||||||
mod shazam;
|
mod shazam;
|
||||||
mod slsk_link;
|
mod slsk_link;
|
||||||
mod smtc;
|
mod smtc;
|
||||||
@@ -136,6 +137,14 @@ pub fn run() {
|
|||||||
friends::friend_decline,
|
friends::friend_decline,
|
||||||
friends::friend_remove,
|
friends::friend_remove,
|
||||||
friends::friend_forget,
|
friends::friend_forget,
|
||||||
|
shared_playlists::shared_playlists_mine,
|
||||||
|
shared_playlists::shared_playlist_publish,
|
||||||
|
shared_playlists::shared_playlist_withdraw,
|
||||||
|
shared_playlists::shared_playlist_fetch,
|
||||||
|
shared_playlists::shared_playlist_import,
|
||||||
|
shared_playlists::share_send,
|
||||||
|
shared_playlists::shares_inbox,
|
||||||
|
shared_playlists::share_dismiss,
|
||||||
commands::buddy_list,
|
commands::buddy_list,
|
||||||
commands::buddy_add,
|
commands::buddy_add,
|
||||||
commands::buddy_remove,
|
commands::buddy_remove,
|
||||||
|
|||||||
@@ -0,0 +1,554 @@
|
|||||||
|
//! Shared playlists (F3 → the app): a playlist is published as an
|
||||||
|
//! immutable, content-addressed REVISION (the portable manifest — entry
|
||||||
|
//! occurrences with display fallbacks, never a path) plus an owner-signed
|
||||||
|
//! POINTER to the current revision per (owner, playlist uuid). Editing
|
||||||
|
//! publishes a new revision and bumps the pointer; withdrawing bumps the
|
||||||
|
//! pointer to "none", and the truthful line stays: published revisions
|
||||||
|
//! remain fetchable, nothing on the network is erased.
|
||||||
|
//!
|
||||||
|
//! Both contract wasms are bundled commitments like the persona ones
|
||||||
|
//! (`assets/playlist_revision_contract.wasm`, `assets/playlist_pointer_
|
||||||
|
//! contract.wasm`, from research/freenet-f3; code hashes pinned by tests).
|
||||||
|
//!
|
||||||
|
//! Sharing WITH someone sends an address, nothing else — `SSSHARE1?<ref>`
|
||||||
|
//! over the same Soulseek PM channel the link proof and friends use. The
|
||||||
|
//! recipient sees a card, fetches the manifest through their own node,
|
||||||
|
//! verifies it against the owner's key, and imports it through the app's
|
||||||
|
//! normal playlist-import path (the review resolves tracks against their
|
||||||
|
//! library and the catalog, and unowned rows become Soulseek streams).
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use freenet_adapter::FreenetAdapter;
|
||||||
|
use persona_state as ps;
|
||||||
|
use persona_state::playlist as pl;
|
||||||
|
use persona_state::slsk_link::normalize_username;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{Emitter, Manager, State};
|
||||||
|
|
||||||
|
use crate::persona;
|
||||||
|
use crate::pipeline::LibraryState;
|
||||||
|
use crate::playlist_formats::{ParsedEntry, ParsedPlaylist};
|
||||||
|
use crate::playlists::{self, ImportDraft};
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const REV_WASM: &[u8] = include_bytes!("../assets/playlist_revision_contract.wasm");
|
||||||
|
const PTR_WASM: &[u8] = include_bytes!("../assets/playlist_pointer_contract.wasm");
|
||||||
|
const SHARE_PREFIX: &str = "SSSHARE1?";
|
||||||
|
pub const EVENT: &str = "community:shares";
|
||||||
|
|
||||||
|
/// A playlist this device has published, by local playlist id.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Published {
|
||||||
|
pub playlist_id: i64,
|
||||||
|
pub uuid: String,
|
||||||
|
pub revision_seq: u64,
|
||||||
|
pub pointer_seq: u64,
|
||||||
|
/// The current revision's content hash (b58); stays after a withdraw so
|
||||||
|
/// the next publication still links to its predecessor.
|
||||||
|
pub current_hash: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub entry_count: u32,
|
||||||
|
pub withdrawn: bool,
|
||||||
|
pub published_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A reference someone sent us: an address, plus what they said it was.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ShareCard {
|
||||||
|
pub id: String,
|
||||||
|
pub from_username: String,
|
||||||
|
pub owner_key: String,
|
||||||
|
pub playlist_uuid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub entry_count: u32,
|
||||||
|
pub received_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default, Serialize, Deserialize)]
|
||||||
|
struct Ledger {
|
||||||
|
#[serde(default)]
|
||||||
|
published: BTreeMap<String, Published>,
|
||||||
|
#[serde(default)]
|
||||||
|
inbox: Vec<ShareCard>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What travels in a share: the address and a display hint.
|
||||||
|
#[derive(Serialize, Deserialize)]
|
||||||
|
struct ShareRef {
|
||||||
|
owner: [u8; 32],
|
||||||
|
playlist_uuid: [u8; 16],
|
||||||
|
name: String,
|
||||||
|
entry_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ShareEntry {
|
||||||
|
pub position: u32,
|
||||||
|
pub title: String,
|
||||||
|
pub artist: String,
|
||||||
|
pub album: String,
|
||||||
|
pub duration_secs: Option<u32>,
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fetched, VERIFIED shared playlist as the reader sees it.
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ShareView {
|
||||||
|
pub owner_key: String,
|
||||||
|
pub playlist_uuid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub revision_seq: u64,
|
||||||
|
pub content_hash: String,
|
||||||
|
pub withdrawn: bool,
|
||||||
|
pub entries: Vec<ShareEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_secs() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
fn rand16() -> [u8; 16] {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut n = [0u8; 16];
|
||||||
|
rand::thread_rng().fill_bytes(&mut n);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
fn b58(bytes: &[u8]) -> String {
|
||||||
|
bs58::encode(bytes).into_string()
|
||||||
|
}
|
||||||
|
fn bytes32(b58s: &str) -> Result<[u8; 32], String> {
|
||||||
|
bs58::decode(b58s)
|
||||||
|
.into_vec()
|
||||||
|
.map_err(|e| format!("bad key: {e}"))?
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "not 32 bytes".to_string())
|
||||||
|
}
|
||||||
|
fn bytes16(b58s: &str) -> Result<[u8; 16], String> {
|
||||||
|
bs58::decode(b58s)
|
||||||
|
.into_vec()
|
||||||
|
.map_err(|e| format!("bad id: {e}"))?
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| "not 16 bytes".to_string())
|
||||||
|
}
|
||||||
|
/// Cut to a byte cap on a char boundary.
|
||||||
|
fn cap(s: &str, max: usize) -> String {
|
||||||
|
if s.len() <= max {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
let mut end = max;
|
||||||
|
while end > 0 && !s.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
s[..end].to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path(app: &tauri::AppHandle) -> Result<PathBuf, String> {
|
||||||
|
Ok(app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.join("freenet")
|
||||||
|
.join("playlists-ledger.json"))
|
||||||
|
}
|
||||||
|
fn load(app: &tauri::AppHandle) -> Ledger {
|
||||||
|
path(app)
|
||||||
|
.ok()
|
||||||
|
.and_then(|p| std::fs::read(p).ok())
|
||||||
|
.and_then(|b| serde_json::from_slice(&b).ok())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
fn save(app: &tauri::AppHandle, ledger: &Ledger) -> Result<(), String> {
|
||||||
|
let p = path(app)?;
|
||||||
|
if let Some(dir) = p.parent() {
|
||||||
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
std::fs::write(&p, serde_json::to_vec_pretty(ledger).map_err(|e| e.to_string())?)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let _ = app.emit(EVENT, ());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn own_key(app: &tauri::AppHandle) -> Result<[u8; 32], String> {
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
let info = persona::current(&adapter)
|
||||||
|
.await?
|
||||||
|
.ok_or("create a SoulStream identity first")?;
|
||||||
|
bytes32(&info.public_key)
|
||||||
|
}
|
||||||
|
async fn send_pm(app: &tauri::AppHandle, to: &str, text: &str) -> Result<(), String> {
|
||||||
|
let st = app.state::<AppState>();
|
||||||
|
let guard = st.client.lock().await;
|
||||||
|
guard
|
||||||
|
.as_ref()
|
||||||
|
.ok_or("not connected to Soulseek")?
|
||||||
|
.message_user(to, text)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- publish
|
||||||
|
|
||||||
|
/// Publish (or re-publish) a local playlist: a new signed revision and a
|
||||||
|
/// pointer bump. Nothing about local files leaves the machine — entries
|
||||||
|
/// carry titles, artists, albums, durations and a text hint only.
|
||||||
|
pub async fn publish(app: &tauri::AppHandle, lib: &LibraryState, playlist_id: i64) -> Result<Published, String> {
|
||||||
|
let adapter: FreenetAdapter = app.state::<AppState>().freenet.clone();
|
||||||
|
let owner = own_key(app).await?;
|
||||||
|
let l = lib.library.clone();
|
||||||
|
let (meta, items) = tauri::async_runtime::spawn_blocking(move || -> Result<_, String> {
|
||||||
|
let meta = l
|
||||||
|
.list_playlists()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.id == playlist_id)
|
||||||
|
.ok_or("no such playlist")?;
|
||||||
|
let items = l.playlist_items(playlist_id).map_err(|e| e.to_string())?;
|
||||||
|
Ok((meta, items))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())??;
|
||||||
|
if items.len() > pl::MAX_ENTRIES {
|
||||||
|
return Err(format!("{} entries is over the {} cap", items.len(), pl::MAX_ENTRIES));
|
||||||
|
}
|
||||||
|
let mut ledger = load(app);
|
||||||
|
let key = playlist_id.to_string();
|
||||||
|
let prev = ledger.published.get(&key).cloned();
|
||||||
|
let uuid = match &prev {
|
||||||
|
Some(p) => bytes16(&p.uuid)?,
|
||||||
|
None => rand16(),
|
||||||
|
};
|
||||||
|
let revision_seq = prev.as_ref().map(|p| p.revision_seq + 1).unwrap_or(1);
|
||||||
|
let prev_hash = match prev.as_ref().and_then(|p| p.current_hash.as_deref()) {
|
||||||
|
Some(h) => Some(bytes32(h)?),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let entries: Vec<pl::EntryV1> = items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, it)| pl::EntryV1 {
|
||||||
|
entry_id: rand16(),
|
||||||
|
position: i as u32,
|
||||||
|
recording: pl::RecordingRef {
|
||||||
|
musicbrainz_recording_id: None,
|
||||||
|
isrcs: vec![],
|
||||||
|
deezer_id: None,
|
||||||
|
apple_music_id: None,
|
||||||
|
title: cap(&it.title, pl::MAX_FIELD_BYTES),
|
||||||
|
artists: vec![cap(&it.artist, pl::MAX_FIELD_BYTES)],
|
||||||
|
album: cap(it.album.as_deref().unwrap_or(""), pl::MAX_FIELD_BYTES),
|
||||||
|
duration_ms: it.duration_secs.map(|s| s.saturating_mul(1000)),
|
||||||
|
release_year: None,
|
||||||
|
},
|
||||||
|
note: it.note.as_deref().map(|n| cap(n, pl::MAX_NOTE_BYTES)),
|
||||||
|
slsk_hint: Some(cap(&format!("{} {}", it.artist, it.title), pl::MAX_FIELD_BYTES)),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut m = pl::ManifestV1 {
|
||||||
|
schema_version: pl::SCHEMA_VERSION,
|
||||||
|
owner,
|
||||||
|
playlist_uuid: uuid,
|
||||||
|
revision_seq,
|
||||||
|
prev_revision_hash: prev_hash,
|
||||||
|
name: cap(&meta.name, pl::MAX_NAME_BYTES),
|
||||||
|
description: cap(meta.description.as_deref().unwrap_or(""), pl::MAX_DESC_BYTES),
|
||||||
|
artwork: None,
|
||||||
|
entries,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
m.sig = persona::sign(&adapter, pl::SIGN_DOMAIN, pl::manifest_sign_payload(&m)).await?;
|
||||||
|
let bytes = bincode::serialize(&m).map_err(|e| e.to_string())?;
|
||||||
|
let hash = ps::content_hash(&bytes);
|
||||||
|
adapter
|
||||||
|
.contract_put(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &hash), bytes.clone(), false)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let pointer_seq = prev.as_ref().map(|p| p.pointer_seq + 1).unwrap_or(1);
|
||||||
|
let mut p = pl::PointerState {
|
||||||
|
schema_version: pl::SCHEMA_VERSION,
|
||||||
|
owner,
|
||||||
|
playlist_uuid: uuid,
|
||||||
|
sequence: pointer_seq,
|
||||||
|
current: Some(pl::RevisionRef {
|
||||||
|
content_hash: hash,
|
||||||
|
revision_seq,
|
||||||
|
entry_count: m.entries.len() as u32,
|
||||||
|
byte_length: bytes.len() as u32,
|
||||||
|
name: m.name.clone(),
|
||||||
|
}),
|
||||||
|
withdrawn: false,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
p.sig = persona::sign(&adapter, pl::SIGN_DOMAIN, pl::pointer_sign_payload(&p)).await?;
|
||||||
|
let pb = bincode::serialize(&p).map_err(|e| e.to_string())?;
|
||||||
|
let params = pl::make_ptr_params(&owner, &uuid);
|
||||||
|
if prev.is_some() {
|
||||||
|
adapter.contract_update(PTR_WASM.to_vec(), ¶ms, pb).await.map(|_| ())
|
||||||
|
} else {
|
||||||
|
adapter.contract_put(PTR_WASM.to_vec(), ¶ms, pb, false).await.map(|_| ())
|
||||||
|
}
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rec = Published {
|
||||||
|
playlist_id,
|
||||||
|
uuid: b58(&uuid),
|
||||||
|
revision_seq,
|
||||||
|
pointer_seq,
|
||||||
|
current_hash: Some(b58(&hash)),
|
||||||
|
name: m.name.clone(),
|
||||||
|
entry_count: m.entries.len() as u32,
|
||||||
|
withdrawn: false,
|
||||||
|
published_at: now_secs(),
|
||||||
|
};
|
||||||
|
ledger.published.insert(key, rec.clone());
|
||||||
|
save(app, &ledger)?;
|
||||||
|
log::info!("shared playlists: published {:?} rev {} ({} entries)", rec.name, revision_seq, rec.entry_count);
|
||||||
|
Ok(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Withdraw: the pointer moves to "none". Revisions already published stay
|
||||||
|
/// fetchable on the network — the ledger keeps saying so.
|
||||||
|
pub async fn withdraw(app: &tauri::AppHandle, playlist_id: i64) -> Result<Published, String> {
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
let owner = own_key(app).await?;
|
||||||
|
let mut ledger = load(app);
|
||||||
|
let key = playlist_id.to_string();
|
||||||
|
let prev = ledger.published.get(&key).cloned().ok_or("that playlist was never published")?;
|
||||||
|
if prev.withdrawn {
|
||||||
|
return Ok(prev);
|
||||||
|
}
|
||||||
|
let uuid = bytes16(&prev.uuid)?;
|
||||||
|
let mut p = pl::PointerState {
|
||||||
|
schema_version: pl::SCHEMA_VERSION,
|
||||||
|
owner,
|
||||||
|
playlist_uuid: uuid,
|
||||||
|
sequence: prev.pointer_seq + 1,
|
||||||
|
current: None,
|
||||||
|
withdrawn: true,
|
||||||
|
sig: vec![],
|
||||||
|
};
|
||||||
|
p.sig = persona::sign(&adapter, pl::SIGN_DOMAIN, pl::pointer_sign_payload(&p)).await?;
|
||||||
|
let pb = bincode::serialize(&p).map_err(|e| e.to_string())?;
|
||||||
|
adapter
|
||||||
|
.contract_update(PTR_WASM.to_vec(), &pl::make_ptr_params(&owner, &uuid), pb)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let rec = Published { pointer_seq: prev.pointer_seq + 1, withdrawn: true, ..prev };
|
||||||
|
ledger.published.insert(key, rec.clone());
|
||||||
|
save(app, &ledger)?;
|
||||||
|
Ok(rec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- read
|
||||||
|
|
||||||
|
/// Resolve (owner, uuid) through the network: pointer, then the revision it
|
||||||
|
/// names, verified in the plan's order before anything is shown.
|
||||||
|
pub async fn fetch(app: &tauri::AppHandle, owner_b58: &str, uuid_b58: &str) -> Result<ShareView, String> {
|
||||||
|
let adapter = app.state::<AppState>().freenet.clone();
|
||||||
|
let owner = bytes32(owner_b58)?;
|
||||||
|
let uuid = bytes16(uuid_b58)?;
|
||||||
|
let ptr_ids = FreenetAdapter::contract_ids(PTR_WASM.to_vec(), &pl::make_ptr_params(&owner, &uuid));
|
||||||
|
let pbytes = adapter.contract_get(&ptr_ids.instance, false).await.map_err(|e| {
|
||||||
|
if e.to_string().contains("not found") {
|
||||||
|
"no such shared playlist on the network (or not reachable yet)".to_string()
|
||||||
|
} else {
|
||||||
|
e.to_string()
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let p = pl::validate_pointer(&owner, &uuid, &pbytes).map_err(|e| format!("pointer failed verification: {e}"))?;
|
||||||
|
let Some(r) = p.current.filter(|_| !p.withdrawn) else {
|
||||||
|
return Ok(ShareView {
|
||||||
|
owner_key: owner_b58.to_string(),
|
||||||
|
playlist_uuid: uuid_b58.to_string(),
|
||||||
|
name: String::new(),
|
||||||
|
description: String::new(),
|
||||||
|
revision_seq: 0,
|
||||||
|
content_hash: String::new(),
|
||||||
|
withdrawn: true,
|
||||||
|
entries: vec![],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
let rev_ids = FreenetAdapter::contract_ids(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &r.content_hash));
|
||||||
|
let rbytes = adapter.contract_get(&rev_ids.instance, false).await.map_err(|e| e.to_string())?;
|
||||||
|
let m = pl::validate_revision(&owner, &r.content_hash, &rbytes)
|
||||||
|
.map_err(|e| format!("revision failed verification: {e}"))?;
|
||||||
|
Ok(ShareView {
|
||||||
|
owner_key: owner_b58.to_string(),
|
||||||
|
playlist_uuid: uuid_b58.to_string(),
|
||||||
|
name: m.name,
|
||||||
|
description: m.description,
|
||||||
|
revision_seq: m.revision_seq,
|
||||||
|
content_hash: b58(&r.content_hash),
|
||||||
|
withdrawn: false,
|
||||||
|
entries: m
|
||||||
|
.entries
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| ShareEntry {
|
||||||
|
position: e.position,
|
||||||
|
title: e.recording.title,
|
||||||
|
artist: e.recording.artists.join(", "),
|
||||||
|
album: e.recording.album,
|
||||||
|
duration_secs: e.recording.duration_ms.map(|ms| ms / 1000),
|
||||||
|
note: e.note,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A verified shared playlist staged through the app's own import path.
|
||||||
|
pub async fn import(
|
||||||
|
app: &tauri::AppHandle,
|
||||||
|
lib: &State<'_, LibraryState>,
|
||||||
|
owner_b58: &str,
|
||||||
|
uuid_b58: &str,
|
||||||
|
) -> Result<ImportDraft, String> {
|
||||||
|
let v = fetch(app, owner_b58, uuid_b58).await?;
|
||||||
|
if v.withdrawn {
|
||||||
|
return Err("the owner withdrew this playlist".into());
|
||||||
|
}
|
||||||
|
let parsed = ParsedPlaylist {
|
||||||
|
format: "soulstream".into(),
|
||||||
|
title: Some(v.name.clone()),
|
||||||
|
description: Some(v.description.clone()).filter(|d| !d.trim().is_empty()),
|
||||||
|
entries: v
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.map(|e| ParsedEntry {
|
||||||
|
ordinal: e.position + 1,
|
||||||
|
title: Some(e.title.clone()),
|
||||||
|
artist: Some(e.artist.clone()),
|
||||||
|
album: Some(e.album.clone()).filter(|a| !a.trim().is_empty()),
|
||||||
|
duration_secs: e.duration_secs,
|
||||||
|
location: None,
|
||||||
|
start_ms: None,
|
||||||
|
raw: format!("{} - {}", e.artist, e.title),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
warnings: vec![],
|
||||||
|
};
|
||||||
|
playlists::resolve_draft(lib, parsed, format!("SoulStream · {}", v.name), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- share carry
|
||||||
|
|
||||||
|
pub async fn send(app: &tauri::AppHandle, username: &str, playlist_id: i64) -> Result<(), String> {
|
||||||
|
let ledger = load(app);
|
||||||
|
let rec = ledger
|
||||||
|
.published
|
||||||
|
.get(&playlist_id.to_string())
|
||||||
|
.ok_or("publish the playlist first")?;
|
||||||
|
if rec.withdrawn {
|
||||||
|
return Err("that playlist is withdrawn — publish it again first".into());
|
||||||
|
}
|
||||||
|
let owner = own_key(app).await?;
|
||||||
|
let r = ShareRef {
|
||||||
|
owner,
|
||||||
|
playlist_uuid: bytes16(&rec.uuid)?,
|
||||||
|
name: rec.name.clone(),
|
||||||
|
entry_count: rec.entry_count,
|
||||||
|
};
|
||||||
|
use base64::Engine;
|
||||||
|
let bytes = bincode::serialize(&r).map_err(|e| e.to_string())?;
|
||||||
|
let text = format!("{SHARE_PREFIX}{}", base64::engine::general_purpose::STANDARD.encode(bytes));
|
||||||
|
send_pm(app, username, &text).await?;
|
||||||
|
log::info!("shared playlists: sent {:?} to {username}", rec.name);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event pump hands private messages here after the link and friend
|
||||||
|
/// handshakes. True means it was a share reference, now a card in the Inbox.
|
||||||
|
pub fn intercept_pm(app: &tauri::AppHandle, from: &str, message: &str) -> bool {
|
||||||
|
let Some(rest) = message.trim().strip_prefix(SHARE_PREFIX) else { return false };
|
||||||
|
use base64::Engine;
|
||||||
|
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(rest.trim()) else { return true };
|
||||||
|
let Ok(r) = bincode::deserialize::<ShareRef>(&bytes) else { return true };
|
||||||
|
let card = ShareCard {
|
||||||
|
id: format!("{}:{}:{}", normalize_username(from), b58(&r.owner), b58(&r.playlist_uuid)),
|
||||||
|
from_username: from.to_string(),
|
||||||
|
owner_key: b58(&r.owner),
|
||||||
|
playlist_uuid: b58(&r.playlist_uuid),
|
||||||
|
name: cap(&r.name, pl::MAX_NAME_BYTES),
|
||||||
|
entry_count: r.entry_count,
|
||||||
|
received_at: now_secs(),
|
||||||
|
};
|
||||||
|
let mut ledger = load(app);
|
||||||
|
ledger.inbox.retain(|c| c.id != card.id);
|
||||||
|
ledger.inbox.push(card);
|
||||||
|
if let Err(e) = save(app, &ledger) {
|
||||||
|
log::warn!("shared playlists: could not keep the share from {from}: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("shared playlists: share received from {from}");
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- commands
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn shared_playlists_mine(app: tauri::AppHandle) -> Vec<Published> {
|
||||||
|
let mut v: Vec<_> = load(&app).published.into_values().collect();
|
||||||
|
v.sort_by(|a, b| b.published_at.cmp(&a.published_at));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn shared_playlist_publish(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
lib: State<'_, LibraryState>,
|
||||||
|
playlist_id: i64,
|
||||||
|
) -> Result<Published, String> {
|
||||||
|
publish(&app, &lib, playlist_id).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn shared_playlist_withdraw(app: tauri::AppHandle, playlist_id: i64) -> Result<Published, String> {
|
||||||
|
withdraw(&app, playlist_id).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn shared_playlist_fetch(app: tauri::AppHandle, owner: String, uuid: String) -> Result<ShareView, String> {
|
||||||
|
fetch(&app, &owner, &uuid).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn shared_playlist_import(
|
||||||
|
app: tauri::AppHandle,
|
||||||
|
lib: State<'_, LibraryState>,
|
||||||
|
owner: String,
|
||||||
|
uuid: String,
|
||||||
|
) -> Result<ImportDraft, String> {
|
||||||
|
import(&app, &lib, &owner, &uuid).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn share_send(app: tauri::AppHandle, username: String, playlist_id: i64) -> Result<(), String> {
|
||||||
|
send(&app, &username, playlist_id).await
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn shares_inbox(app: tauri::AppHandle) -> Vec<ShareCard> {
|
||||||
|
let mut v = load(&app).inbox;
|
||||||
|
v.sort_by(|a, b| b.received_at.cmp(&a.received_at));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn share_dismiss(app: tauri::AppHandle, id: String) -> Result<(), String> {
|
||||||
|
let mut ledger = load(&app);
|
||||||
|
ledger.inbox.retain(|c| c.id != id);
|
||||||
|
save(&app, &ledger)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// The bundled contracts are commitments (copy-forward is disabled
|
||||||
|
/// upstream): a changed wasm re-keys every published playlist.
|
||||||
|
#[test]
|
||||||
|
fn contract_generations_are_pinned() {
|
||||||
|
let owner = [7u8; 32];
|
||||||
|
let rev = FreenetAdapter::contract_ids(REV_WASM.to_vec(), &pl::make_rev_params(&owner, &[1u8; 32]));
|
||||||
|
let ptr = FreenetAdapter::contract_ids(PTR_WASM.to_vec(), &pl::make_ptr_params(&owner, &[2u8; 16]));
|
||||||
|
assert_eq!(rev.code_hash, "2JYdHHe3j59CVwCCKKeicjaF3rqoY2qjEbrYc95uUjkk");
|
||||||
|
assert_eq!(ptr.code_hash, "Daqn9EMctrTniK5s5x4m9QBHYixsf265UMUAmLPATg5S");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -162,6 +162,14 @@ export const friendAccept = (id) => invoke("friend_accept", { id });
|
|||||||
export const friendDecline = (id) => invoke("friend_decline", { id });
|
export const friendDecline = (id) => invoke("friend_decline", { id });
|
||||||
export const friendRemove = (id) => invoke("friend_remove", { id });
|
export const friendRemove = (id) => invoke("friend_remove", { id });
|
||||||
export const friendForget = (id) => invoke("friend_forget", { id });
|
export const friendForget = (id) => invoke("friend_forget", { id });
|
||||||
|
export const sharedPlaylistsMine = () => invoke("shared_playlists_mine");
|
||||||
|
export const sharedPlaylistPublish = (playlistId) => invoke("shared_playlist_publish", { playlistId });
|
||||||
|
export const sharedPlaylistWithdraw = (playlistId) => invoke("shared_playlist_withdraw", { playlistId });
|
||||||
|
export const sharedPlaylistFetch = (owner, uuid) => invoke("shared_playlist_fetch", { owner, uuid });
|
||||||
|
export const sharedPlaylistImport = (owner, uuid) => invoke("shared_playlist_import", { owner, uuid });
|
||||||
|
export const shareSend = (username, playlistId) => invoke("share_send", { username, playlistId });
|
||||||
|
export const sharesInbox = () => invoke("shares_inbox");
|
||||||
|
export const shareDismiss = (id) => invoke("share_dismiss", { id });
|
||||||
export const buddyList = () => invoke("buddy_list");
|
export const buddyList = () => invoke("buddy_list");
|
||||||
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
export const buddyAdd = (username) => invoke("buddy_add", { username });
|
||||||
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
export const buddyRemove = (username) => invoke("buddy_remove", { username });
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export const community = $state({
|
|||||||
linkErrors: {},
|
linkErrors: {},
|
||||||
/** friends_list: every relationship this device holds, newest first. */
|
/** friends_list: every relationship this device holds, newest first. */
|
||||||
friends: [],
|
friends: [],
|
||||||
|
/** shares_inbox: playlist references people sent us, newest first. */
|
||||||
|
shares: [],
|
||||||
|
/** card id → fetched ShareView | {pending} | {error} (the Verify button). */
|
||||||
|
sharePreviews: {},
|
||||||
|
/** shared_playlists_mine: what this device has published. */
|
||||||
|
published: [],
|
||||||
/** persona key → ProfileView | {none} | {error} | {pending}: other
|
/** persona key → ProfileView | {none} | {error} | {pending}: other
|
||||||
* people's signed profiles, read once per session when surfaced. */
|
* people's signed profiles, read once per session when surfaced. */
|
||||||
remoteProfiles: {},
|
remoteProfiles: {},
|
||||||
@@ -309,11 +315,15 @@ export async function initCommunity() {
|
|||||||
else if (community.persona) community.persona.available = false;
|
else if (community.persona) community.persona.available = false;
|
||||||
});
|
});
|
||||||
api.onEvent("community:friends", () => void refreshFriends());
|
api.onEvent("community:friends", () => void refreshFriends());
|
||||||
|
api.onEvent("community:shares", () => {
|
||||||
|
void refreshShares();
|
||||||
|
void refreshPublished();
|
||||||
|
});
|
||||||
api.onEvent("soulseek:user-state", (p) => {
|
api.onEvent("soulseek:user-state", (p) => {
|
||||||
if (p.status === 1 || p.status === 2) noteSeen(p.username);
|
if (p.status === 1 || p.status === 2) noteSeen(p.username);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends()]);
|
await Promise.all([refreshFreenet(), loadContacts(), refreshLinks(), refreshFriends(), refreshShares(), refreshPublished()]);
|
||||||
await refreshPersona();
|
await refreshPersona();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +429,67 @@ export const friendCount = () => community.friends.filter((f) => f.state === "fr
|
|||||||
export const pendingIn = () => community.friends.filter((f) => f.state === "pending-in");
|
export const pendingIn = () => community.friends.filter((f) => f.state === "pending-in");
|
||||||
|
|
||||||
|
|
||||||
|
// ---- shared playlists ---------------------------------------------------------------
|
||||||
|
export async function refreshShares() {
|
||||||
|
try {
|
||||||
|
community.shares = await api.sharesInbox();
|
||||||
|
} catch {
|
||||||
|
// stays as it was
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function refreshPublished() {
|
||||||
|
try {
|
||||||
|
community.published = await api.sharedPlaylistsMine();
|
||||||
|
} catch {
|
||||||
|
// stays as it was
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const publishedFor = (playlistId) => community.published.find((p) => p.playlist_id === playlistId) ?? null;
|
||||||
|
export const sharedPublish = (playlistId) =>
|
||||||
|
run("publish:" + playlistId, async () => {
|
||||||
|
const r = await api.sharedPlaylistPublish(playlistId);
|
||||||
|
await refreshPublished();
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
export const sharedWithdraw = (playlistId) =>
|
||||||
|
run("publish:" + playlistId, async () => {
|
||||||
|
await api.sharedPlaylistWithdraw(playlistId);
|
||||||
|
await refreshPublished();
|
||||||
|
});
|
||||||
|
export const shareSend = (username, playlistId) => run("share:" + playlistId, () => api.shareSend(username, playlistId));
|
||||||
|
export const shareDismiss = (id) =>
|
||||||
|
run("share:" + id, async () => {
|
||||||
|
await api.shareDismiss(id);
|
||||||
|
await refreshShares();
|
||||||
|
});
|
||||||
|
/** Fetch and verify a shared playlist through our node — nothing imported. */
|
||||||
|
export async function sharePreview(card) {
|
||||||
|
community.sharePreviews[card.id] = { pending: true };
|
||||||
|
try {
|
||||||
|
community.sharePreviews[card.id] = await api.sharedPlaylistFetch(card.owner_key, card.playlist_uuid);
|
||||||
|
} catch (e) {
|
||||||
|
community.sharePreviews[card.id] = { error: String(e?.message ?? e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** Fetch, verify, stage through the app's import path, commit — the new
|
||||||
|
* playlist's id comes back; the card is dismissed. */
|
||||||
|
export const shareImport = (card) =>
|
||||||
|
run("import:" + card.id, async () => {
|
||||||
|
const draft = await api.sharedPlaylistImport(card.owner_key, card.playlist_uuid);
|
||||||
|
const id = await api.playlistImportCommit(
|
||||||
|
draft.name ?? card.name,
|
||||||
|
draft.description ?? null,
|
||||||
|
draft.kind ?? "manual",
|
||||||
|
draft.entries,
|
||||||
|
draft.cover_url ?? null,
|
||||||
|
);
|
||||||
|
await api.shareDismiss(card.id);
|
||||||
|
await refreshShares();
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
|
||||||
// through this, never through a bare import (which would be a second copy).
|
// through this, never through a bare import (which would be a second copy).
|
||||||
if (import.meta.env.DEV) globalThis.__community = community;
|
if (import.meta.env.DEV) globalThis.__community = community;
|
||||||
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends };
|
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished };
|
||||||
@@ -72,6 +72,11 @@
|
|||||||
friendshipFor,
|
friendshipFor,
|
||||||
friendCount,
|
friendCount,
|
||||||
pendingIn,
|
pendingIn,
|
||||||
|
sharePreview,
|
||||||
|
shareImport,
|
||||||
|
shareDismiss,
|
||||||
|
sharedPublish,
|
||||||
|
sharedWithdraw,
|
||||||
} from "../lib/community.svelte.js";
|
} from "../lib/community.svelte.js";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -423,6 +428,10 @@
|
|||||||
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
|
const drawerLink = $derived(drawerUser ? linkFor(drawerUser) : null);
|
||||||
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
|
const drawerRemote = $derived(drawerLink ? community.remoteProfiles[drawerLink.persona_key] : null);
|
||||||
const drawerFriend = $derived(drawerUser ? friendshipFor(drawerUser) : null);
|
const drawerFriend = $derived(drawerUser ? friendshipFor(drawerUser) : null);
|
||||||
|
async function importShare(card) {
|
||||||
|
const id = await shareImport(card);
|
||||||
|
if (id) goToView("playlist", { playlistId: id, label: card.name });
|
||||||
|
}
|
||||||
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
|
const isMe = (u) => !!me && (u ?? "").trim().toLowerCase() === me.trim().toLowerCase();
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const lk = drawerLink;
|
const lk = drawerLink;
|
||||||
@@ -569,7 +578,36 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if !convos.length}
|
{#if community.shares.length}
|
||||||
|
<div class="freqs">
|
||||||
|
{#each community.shares as c (c.id)}
|
||||||
|
{@const pv = community.sharePreviews[c.id]}
|
||||||
|
<div class="card">
|
||||||
|
<div class="top">
|
||||||
|
<span class="mosaic-ph"><Icon name="playlist" size={22} /></span>
|
||||||
|
<div>
|
||||||
|
<div class="t">{c.name}</div>
|
||||||
|
<div class="s">{c.entry_count} {c.entry_count === 1 ? "track" : "tracks"} · playlist · shared by <button class="lnkname" onclick={() => (community.drawer = c.from_username)}>{display(c.from_username)}</button> {fmtWhen(c.received_at * 1000)}</div>
|
||||||
|
<div class="prov">
|
||||||
|
<span class="src ss"><Icon name="disc" size={10} />SoulStream</span>
|
||||||
|
<span>signed by {fpShort(c.owner_key)}</span>
|
||||||
|
{#if pv && !pv.pending && !pv.error && !pv.withdrawn}<span>·</span><span class="chip ok"><Icon name="check" size={9} />verified · revision {pv.revision_seq}</span>{/if}
|
||||||
|
{#if pv?.withdrawn}<span>·</span><span class="chip warn">withdrawn by the owner</span>{/if}
|
||||||
|
{#if pv?.error}<span>·</span><span class="chip err">{pv.error}</span>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bot">
|
||||||
|
<button class="ax-fill sm" onclick={() => importShare(c)} disabled={!nodeUp || !!community.busy["import:" + c.id] || pv?.withdrawn}><Icon name="download" size={12} /> {community.busy["import:" + c.id] ? "Importing…" : "Import"}</button>
|
||||||
|
<button class="ax-ghost sm" onclick={() => sharePreview(c)} disabled={!nodeUp || pv?.pending}>{pv?.pending ? "Fetching…" : "Verify"}</button>
|
||||||
|
<button class="lnk" onclick={() => shareDismiss(c.id)}>dismiss</button>
|
||||||
|
<span class="hint">tracks resolve against your library first; the rest become Soulseek streams</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !convos.length && !community.shares.length && !pendingIn().length}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<Icon name="inbox" size={30} />
|
<Icon name="inbox" size={30} />
|
||||||
<div class="big">No conversations yet</div>
|
<div class="big">No conversations yet</div>
|
||||||
@@ -1044,6 +1082,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if key}
|
{#if key}
|
||||||
<div class="yc b">
|
<div class="yc b">
|
||||||
|
<div class="panel">
|
||||||
|
<h3><Icon name="playlist" size={13} /> Your public playlists <span class="chip off">{community.published.filter((p) => !p.withdrawn).length}</span></h3>
|
||||||
|
{#if !community.published.length}
|
||||||
|
<p class="hint">Nothing published yet. Open a playlist and choose <b>Share on SoulStream</b>: a signed manifest goes to the network under your ID — titles, artists, albums, durations, your notes; never a file, never a path.</p>
|
||||||
|
{:else}
|
||||||
|
{#each community.published as p (p.playlist_id)}
|
||||||
|
<div class="pubrow">
|
||||||
|
<Icon name="playlist" size={14} />
|
||||||
|
<div class="ptx"><div class="nm">{p.name}</div><div class="hint">{p.entry_count} tracks · revision {p.revision_seq} · {p.withdrawn ? "withdrawn" : "published"} {fmtWhen(p.published_at * 1000)}</div></div>
|
||||||
|
{#if p.withdrawn}
|
||||||
|
<button class="ax-ghost sm" onclick={() => sharedPublish(p.playlist_id)} disabled={!nodeUp || !!community.busy["publish:" + p.playlist_id]}>Publish again</button>
|
||||||
|
{:else}
|
||||||
|
<button class="ax-ghost sm" onclick={() => sharedWithdraw(p.playlist_id)} disabled={!nodeUp || !!community.busy["publish:" + p.playlist_id]}>Withdraw</button>
|
||||||
|
{/if}
|
||||||
|
<button class="ax-ghost sm" onclick={() => goToView("playlist", { playlistId: p.playlist_id, label: p.name })}>Open</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<p class="hint">Withdrawing stops your pointer; copies others already fetched stay with them.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h3><Icon name="key" size={13} /> Your SoulStream ID</h3>
|
<h3><Icon name="key" size={13} /> Your SoulStream ID</h3>
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
@@ -1704,6 +1762,10 @@
|
|||||||
.rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
|
.rp .nm { font-size: 15px; font-weight: 800; display: flex; align-items: center; gap: 8px; }
|
||||||
.friendrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
.friendrow { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
|
||||||
.mark.fr { color: var(--accent-hover); }
|
.mark.fr { color: var(--accent-hover); }
|
||||||
|
.pubrow { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--hair); }
|
||||||
|
.pubrow .ptx { flex: 1; min-width: 0; }
|
||||||
|
.pubrow .nm { font-size: 13px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.mosaic-ph { width: 56px; height: 56px; border-radius: 8px; flex: none; display: grid; place-items: center; background: var(--accent-soft); color: var(--accent-hover); }
|
||||||
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
|
.dsec { margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--hair); }
|
||||||
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
|
.dsec h4 { margin: 0 0 8px; font-size: 10.5px; font-weight: 900; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-muted); display: flex; align-items: center; gap: 8px; }
|
||||||
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }
|
.dsec h4 .src, .dsec h4 .chip { margin-left: auto; }
|
||||||
|
|||||||
@@ -26,6 +26,16 @@
|
|||||||
import * as api from "../lib/api.js";
|
import * as api from "../lib/api.js";
|
||||||
import { findCatalogTrack } from "../lib/catalogMatch.js";
|
import { findCatalogTrack } from "../lib/catalogMatch.js";
|
||||||
import Icon from "../components/Icon.svelte";
|
import Icon from "../components/Icon.svelte";
|
||||||
|
import {
|
||||||
|
community,
|
||||||
|
initCommunity,
|
||||||
|
refreshPublished,
|
||||||
|
publishedFor,
|
||||||
|
sharedPublish,
|
||||||
|
sharedWithdraw,
|
||||||
|
shareSend,
|
||||||
|
statusKind,
|
||||||
|
} from "../lib/community.svelte.js";
|
||||||
|
|
||||||
const playlist = $derived(
|
const playlist = $derived(
|
||||||
playlistStore.playlists.find((p) => p.id === ui.playlistId) ?? null,
|
playlistStore.playlists.find((p) => p.id === ui.playlistId) ?? null,
|
||||||
@@ -37,6 +47,54 @@
|
|||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
let confirmDelete = $state(false);
|
let confirmDelete = $state(false);
|
||||||
let exportOpen = $state(false);
|
let exportOpen = $state(false);
|
||||||
|
// ---- Share on SoulStream (shared_playlists.rs) ----
|
||||||
|
let shareOpen = $state(false);
|
||||||
|
let shareTo = $state("");
|
||||||
|
let shareNote = $state("");
|
||||||
|
const friendNames = $derived(community.friends.filter((f) => f.state === "friends").map((f) => f.username));
|
||||||
|
const nodeUp = $derived(statusKind(community.freenet?.status) === "connected");
|
||||||
|
const canPublish = $derived(nodeUp && !!community.persona?.persona && !community.busy["publish:" + playlist?.id]);
|
||||||
|
async function openShare() {
|
||||||
|
menuOpen = false;
|
||||||
|
shareOpen = true;
|
||||||
|
shareNote = "";
|
||||||
|
await initCommunity();
|
||||||
|
await refreshPublished();
|
||||||
|
}
|
||||||
|
async function doPublish() {
|
||||||
|
shareNote = "";
|
||||||
|
try {
|
||||||
|
const r = await sharedPublish(playlist.id);
|
||||||
|
shareNote = `Published revision ${r.revision_seq} — ${r.entry_count} tracks.`;
|
||||||
|
} catch (e) {
|
||||||
|
shareNote = String(e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function doWithdraw() {
|
||||||
|
shareNote = "";
|
||||||
|
try {
|
||||||
|
await sharedWithdraw(playlist.id);
|
||||||
|
shareNote = "Withdrawn. Copies others already fetched stay with them.";
|
||||||
|
} catch (e) {
|
||||||
|
shareNote = String(e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function sendShare() {
|
||||||
|
const to = shareTo.trim();
|
||||||
|
if (!to) return;
|
||||||
|
shareNote = "";
|
||||||
|
try {
|
||||||
|
await shareSend(to, playlist.id);
|
||||||
|
shareNote = `Sent to ${to} over Soulseek.`;
|
||||||
|
} catch (e) {
|
||||||
|
shareNote = String(e?.message ?? e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The sheet escapes the content pane (a containing block) to the body.
|
||||||
|
function portal(node) {
|
||||||
|
document.body.appendChild(node);
|
||||||
|
return { destroy: () => node.remove() };
|
||||||
|
}
|
||||||
let renaming = $state(false);
|
let renaming = $state(false);
|
||||||
let nameInput = $state("");
|
let nameInput = $state("");
|
||||||
let editingDesc = $state(false);
|
let editingDesc = $state(false);
|
||||||
@@ -782,6 +840,9 @@
|
|||||||
>
|
>
|
||||||
<Icon name="download" size={15} /> Download all tracks
|
<Icon name="download" size={15} /> Download all tracks
|
||||||
</button>
|
</button>
|
||||||
|
<button class="mi" onclick={openShare} title="Publish a signed copy under your SoulStream ID, or send it to someone">
|
||||||
|
<Icon name="disc" size={15} /> Share on SoulStream
|
||||||
|
</button>
|
||||||
<div class="rule"></div>
|
<div class="rule"></div>
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
<div
|
<div
|
||||||
@@ -1026,6 +1087,45 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if shareOpen && playlist}
|
||||||
|
{@const pub = publishedFor(playlist.id)}
|
||||||
|
<div class="share-overlay" use:portal>
|
||||||
|
<div class="share-scrim" role="presentation" onclick={() => (shareOpen = false)}></div>
|
||||||
|
<div class="share-sheet" role="dialog" aria-label="Share on SoulStream">
|
||||||
|
<h3>Share on SoulStream</h3>
|
||||||
|
<p class="sh-hint">{playlist.name} · {playlist.track_count} tracks</p>
|
||||||
|
<div class="sh-row">
|
||||||
|
{#if pub && !pub.withdrawn}
|
||||||
|
<span class="sh-chip ok">published · revision {pub.revision_seq}</span>
|
||||||
|
{:else if pub}
|
||||||
|
<span class="sh-chip warn">withdrawn</span>
|
||||||
|
{:else}
|
||||||
|
<span class="sh-chip">not published</span>
|
||||||
|
{/if}
|
||||||
|
<button class="pill" onclick={doPublish} disabled={!canPublish}>{community.busy["publish:" + playlist.id] ? "Publishing…" : pub && !pub.withdrawn ? "Publish update" : "Publish"}</button>
|
||||||
|
{#if pub && !pub.withdrawn}<button class="pill ghost" onclick={doWithdraw} disabled={!canPublish}>Withdraw</button>{/if}
|
||||||
|
</div>
|
||||||
|
<p class="sh-hint">
|
||||||
|
{#if !community.persona?.persona}
|
||||||
|
Needs a SoulStream identity — Community → You.
|
||||||
|
{:else if !nodeUp}
|
||||||
|
Needs the Freenet node connected — Community → You.
|
||||||
|
{:else}
|
||||||
|
A signed manifest goes to the network under your SoulStream ID: titles, artists, albums, durations and your notes — never a file, never a path. Withdrawing stops pointing at it; copies others already fetched stay with them.
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
<div class="sh-row">
|
||||||
|
<input placeholder="Send to a Soulseek username" bind:value={shareTo} disabled={!pub || pub.withdrawn} />
|
||||||
|
<button class="pill" onclick={sendShare} disabled={!pub || pub.withdrawn || !shareTo.trim() || !!community.busy["share:" + playlist.id]}>Send</button>
|
||||||
|
</div>
|
||||||
|
{#if friendNames.length}
|
||||||
|
<p class="sh-hint">Friends: {#each friendNames as f (f)}<button class="sh-lnk" onclick={() => (shareTo = f)}>{f}</button>{/each}</p>
|
||||||
|
{/if}
|
||||||
|
{#if shareNote}<p class="sh-hint sh-note">{shareNote}</p>{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* The glass bar cancels the pane's top padding so the page's box starts
|
/* The glass bar cancels the pane's top padding so the page's box starts
|
||||||
flush with the top of the pane and the banner can run under the frost;
|
flush with the top of the pane and the banner can run under the frost;
|
||||||
@@ -1954,4 +2054,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Menus stay solid on purpose — see the note in Playlists.svelte. */
|
/* Menus stay solid on purpose — see the note in Playlists.svelte. */
|
||||||
|
|
||||||
|
/* ── Share on SoulStream: a body-level sheet ── */
|
||||||
|
.share-scrim { position: fixed; inset: 0; z-index: 60; background: rgba(0, 0, 0, 0.45); }
|
||||||
|
.share-sheet { position: fixed; z-index: 61; left: 50%; top: 18%; transform: translateX(-50%); width: min(480px, calc(100vw - 40px)); background: var(--bg-elevated); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 14px; padding: 18px 20px 16px; box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); font-family: var(--app-font); font-size: 13px; color: var(--text-primary); }
|
||||||
|
.share-sheet h3 { margin: 0 0 4px; font-size: 15px; font-weight: 800; }
|
||||||
|
.sh-hint { margin: 6px 0 0; font-size: 11.5px; line-height: 1.5; color: var(--text-muted); }
|
||||||
|
.sh-note { color: var(--text-secondary); }
|
||||||
|
.sh-row { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.sh-row input { flex: 1; min-width: 160px; background: var(--bg-highlight); border: 1px solid transparent; border-radius: 8px; color: #fff; padding: 8px 12px; font: inherit; outline: none; }
|
||||||
|
.sh-row input:focus { border-color: rgba(255, 255, 255, 0.28); }
|
||||||
|
.sh-chip { font-size: 10px; font-weight: 900; letter-spacing: 0.05em; padding: 2px 8px; border-radius: 99px; background: var(--bg-highlight); color: var(--text-muted); }
|
||||||
|
.sh-chip.ok { background: color-mix(in srgb, #3ddc84 14%, transparent); color: #5ee89a; }
|
||||||
|
.sh-chip.warn { background: color-mix(in srgb, #e8a33d 13%, transparent); color: #e8a33d; }
|
||||||
|
.sh-lnk { color: var(--text-secondary); font: inherit; font-size: 11.5px; font-weight: 700; text-decoration: underline; text-underline-offset: 2px; margin-right: 8px; }
|
||||||
|
.sh-lnk:hover { color: #fff; }
|
||||||
|
.share-sheet .pill.ghost { background: transparent; border: 1px solid rgba(255, 255, 255, 0.28); color: var(--text-primary); }
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user