spaces: a room carries what its members are playing, sealed in the window its chat already uses

This commit is contained in:
KeelrLeek committed 2026-09-04 12:20:27 -05:00
1 parent 4ca9ab043b
commit df06f849e0
6 files changed
+459 -14

No files matched your search

+2
View File
@@ -173,6 +173,8 @@ pub fn run() {
spaces::space_remove_member,
spaces::space_set_pinned,
spaces::space_leave,
spaces::space_set_share_listens,
spaces::space_report_listen,
proposals::space_board,
proposals::space_propose,
proposals::space_decide,
+257 -13
View File
@@ -61,6 +61,16 @@ struct SpaceRecord {
/// OWNER ONLY: personas we have invited and will accept a join from.
#[serde(default)]
invited: BTreeMap<String, u64>,
/// Whether this device posts what it plays into THIS Space. Per Space and
/// off until asked: a listen is as personal as a message, and being in a
/// room is not consent to be watched in it.
#[serde(default)]
share_listens: bool,
/// The last listen posted here — what, and when. A reload or a restart
/// re-reports whatever is already past the player's threshold, and this
/// is the only side that knows what a room was last told.
#[serde(default)]
last_listen: Option<(String, u64)>,
/// When we last told the owner which key we would wear. The answer is one
/// sealed message and it can simply fail to land — the owner's descriptor
/// may not resolve on a young node — and a member who is never seated has
@@ -107,6 +117,21 @@ pub struct MessageView {
pub text: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct ListenView {
pub id: String,
pub author: String,
pub username: Option<String>,
pub is_me: bool,
pub created_at: u64,
pub epoch: u64,
pub title: String,
pub artist: String,
pub album: String,
pub duration_secs: Option<u32>,
pub track_id: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SpaceView {
pub id: String,
@@ -122,13 +147,67 @@ pub struct SpaceView {
pub pinned_owner: Option<String>,
pub pinned_uuid: Option<String>,
pub messages: Vec<MessageView>,
/// The room's trail. Same window as `messages` — a busy room's listens
/// push its chat out, which is why posting them is throttled and opt-in.
pub listens: Vec<ListenView>,
pub can_post: bool,
/// Whether THIS device posts what it plays here.
pub share_listens: bool,
pub has_key: bool,
/// The contract exists on the network (a Space someone invited us to
/// may not have propagated to this node yet).
pub reachable: bool,
}
/// What a listen carries. It travels as the message BODY, which the contract
/// treats as opaque bytes — the same reason room-local member keys cost no
/// contract generation. Sealed with everything else in a private Space.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListenBody {
pub title: String,
#[serde(default)]
pub artist: String,
#[serde(default)]
pub album: String,
#[serde(default)]
pub duration_secs: Option<u32>,
/// The catalog id when this device knows one, so a listen can be found
/// again rather than only read.
#[serde(default)]
pub track_id: Option<String>,
}
/// Chat has always been the raw UTF-8 of what was typed, and every message
/// already on the network is that. A typed body therefore has to be something
/// chat can never be: posting trims and refuses empty, so no chat line starts
/// with a NUL.
const LISTEN_MAGIC: &[u8] = b"\0SSLISTEN1";
/// Per field. Well under MAX_BODY_BYTES even when every one is full, so a
/// listen can never be the thing that fails to fit.
const MAX_LISTEN_FIELD: usize = 200;
fn encode_listen(l: &ListenBody) -> Result<Vec<u8>, String> {
let mut v = LISTEN_MAGIC.to_vec();
v.extend_from_slice(&serde_json::to_vec(l).map_err(|e| e.to_string())?);
if v.len() > sp::MAX_BODY_BYTES {
return Err("that track's details are too long to post".into());
}
Ok(v)
}
/// A decoded body: a listen, or the chat line it has always been.
enum Body {
Listen(ListenBody),
Text(String),
}
fn decode_body(bytes: &[u8]) -> Option<Body> {
match bytes.strip_prefix(LISTEN_MAGIC) {
Some(rest) => serde_json::from_slice(rest).ok().map(Body::Listen),
None => String::from_utf8(bytes.to_vec()).ok().map(Body::Text),
}
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -298,6 +377,15 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
let key_for = |epoch: u64| -> Option<[u8; 32]> {
rec.keys.get(&epoch.to_string()).and_then(|k| key_of(k).ok())
};
// The body as it was written. None means sealed to an epoch key this
// device never received — which is a state to SHOW, not to guess past.
let plain = |m: &sp::Message| -> Option<Vec<u8>> {
if rec.private {
key_for(m.epoch).and_then(|k| sp::open_body(&k, m))
} else {
Some(m.body.clone())
}
};
let Some(c) = &state.checkpoint else {
return SpaceView {
id: record_id(&rec.owner, &rec.space_id),
@@ -313,7 +401,9 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
pinned_owner: None,
pinned_uuid: None,
messages: vec![],
listens: vec![],
can_post: false,
share_listens: rec.share_listens,
has_key: false,
reachable,
};
@@ -355,13 +445,11 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
messages: state
.messages
.iter()
.map(|m| {
let text = if c.private {
key_for(m.epoch).and_then(|k| sp::open_body(&k, m)).and_then(|b| String::from_utf8(b).ok())
} else {
String::from_utf8(m.body.clone()).ok()
};
MessageView {
// A body we cannot open stays in the chat as the sealed line it
// has always been: without the key there is no way to know it was
// a listen, and guessing would quietly drop it from both lists.
.filter(|m| !matches!(plain(m).as_deref().and_then(decode_body), Some(Body::Listen(_))))
.map(|m| MessageView {
id: b58(&m.msg_id),
author: b58(&m.author),
username: slsk_link::username_for_key(
@@ -371,11 +459,37 @@ fn view_of(app: &tauri::AppHandle, rec: &SpaceRecord, state: &sp::SpaceState, me
is_me: &m.author == me,
created_at: m.created_at,
epoch: m.epoch,
text,
}
text: plain(m).as_deref().and_then(decode_body).and_then(|b| match b {
Body::Text(t) => Some(t),
Body::Listen(_) => None,
}),
})
.collect(),
listens: state
.messages
.iter()
.filter_map(|m| match plain(m).as_deref().and_then(decode_body) {
Some(Body::Listen(l)) => Some(ListenView {
id: b58(&m.msg_id),
author: b58(&m.author),
username: slsk_link::username_for_key(
app,
rec.roster.get(&b58(&m.author)).map(String::as_str).unwrap_or(&b58(&m.author)),
),
is_me: &m.author == me,
created_at: m.created_at,
epoch: m.epoch,
title: l.title,
artist: l.artist,
album: l.album,
duration_secs: l.duration_secs,
track_id: l.track_id,
}),
_ => None,
})
.collect(),
can_post: is_member && has_key,
share_listens: rec.share_listens,
has_key,
reachable,
}
@@ -448,6 +562,8 @@ pub async fn create(app: &tauri::AppHandle, name: &str, description: &str, priva
room_secret: None,
roster: BTreeMap::new(),
invited: BTreeMap::new(),
share_listens: false,
last_listen: None,
join_sent_at: 0,
};
let _ledger = ledger::hold(LEDGER_FILE).await;
@@ -495,9 +611,6 @@ pub async fn open(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> R
}
pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text: &str) -> Result<(), String> {
let me = my_key(app).await?;
let owner = key_of(owner_b58)?;
let space_id = id_of(space_b58)?;
let text = text.trim();
if text.is_empty() {
return Err("nothing to say".into());
@@ -505,6 +618,15 @@ pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text
if text.len() > sp::MAX_BODY_BYTES {
return Err(format!("over {} bytes", sp::MAX_BODY_BYTES));
}
post_body(app, owner_b58, space_b58, text.as_bytes().to_vec()).await
}
/// Everything a member puts in the window goes through here: chat, and the
/// listens that ride the same sealed body.
async fn post_body(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, body: Vec<u8>) -> Result<(), String> {
let me = my_key(app).await?;
let owner = key_of(owner_b58)?;
let space_id = id_of(space_b58)?;
let (state, _) = fetch_state(app, &owner, &space_id).await?;
let c = state.checkpoint.as_ref().ok_or("the Space has not reached this node yet")?;
let (worn, room, epoch_key) = {
@@ -531,7 +653,7 @@ pub async fn post(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, text
msg_id: rand16(),
author: worn,
created_at: now_secs(),
body: text.as_bytes().to_vec(),
body,
sig: vec![],
};
if let Some(key) = epoch_key {
@@ -702,6 +824,71 @@ pub async fn set_pinned(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str
open(app, owner_b58, space_b58).await
}
/// Turn posting-what-I-play on or off for one Space. Nothing is published
/// by flipping it; it only decides what the next track does.
pub async fn set_share_listens(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str, on: bool) -> Result<SpaceView, String> {
{
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
let rec = l.spaces.get_mut(&record_id(owner_b58, space_b58)).ok_or("not a Space this device knows")?;
rec.share_listens = on;
save(app, &l)?;
}
open(app, owner_b58, space_b58).await
}
/// The frontend played something through. Which Spaces hear about it is this
/// side's business — the player knows the track, the ledger knows the rooms.
///
/// Best-effort per Space and deliberately quiet: a listen is not worth an
/// error in front of someone who is only listening to music. A Space we
/// cannot post to (no key for this epoch, not seated, node down) is skipped.
pub async fn report_listen(app: &tauri::AppHandle, l: ListenBody) -> Result<u32, String> {
let listen = ListenBody {
title: cap(l.title.trim(), MAX_LISTEN_FIELD),
artist: cap(l.artist.trim(), MAX_LISTEN_FIELD),
album: cap(l.album.trim(), MAX_LISTEN_FIELD),
duration_secs: l.duration_secs,
track_id: l.track_id.filter(|t| !t.is_empty()).map(|t| cap(&t, MAX_LISTEN_FIELD)),
};
if listen.title.is_empty() {
return Err("a listen needs a title".into());
}
let body = encode_listen(&listen)?;
// You cannot have listened to the same track twice inside one play of it,
// so a repeat that soon is the player saying it again — a reload, a
// restart, a seek back — and not a second listen. Where the length is
// unknown, ten minutes stands in.
let key = format!("{}|{}", listen.title, listen.artist);
let window = listen.duration_secs.map(u64::from).unwrap_or(600).max(60);
let now = now_secs();
let rooms: Vec<(String, String)> = load(app)
.spaces
.values()
.filter(|r| r.share_listens)
.filter(|r| !r.last_listen.as_ref().is_some_and(|(k, at)| k == &key && now.saturating_sub(*at) < window))
.map(|r| (r.owner.clone(), r.space_id.clone()))
.collect();
let mut posted = Vec::new();
for (owner, space) in rooms {
match post_body(app, &owner, &space, body.clone()).await {
Ok(()) => posted.push(record_id(&owner, &space)),
Err(e) => log::info!("spaces: no listen into {space} ({e})"),
}
}
if !posted.is_empty() {
let _ledger = ledger::hold(LEDGER_FILE).await;
let mut l = load(app);
for rid in &posted {
if let Some(rec) = l.spaces.get_mut(rid) {
rec.last_listen = Some((key.clone(), now));
}
}
save(app, &l)?;
}
Ok(posted.len() as u32)
}
pub async fn leave(app: &tauri::AppHandle, owner_b58: &str, space_b58: &str) -> Result<(), String> {
let me = my_key(app).await?;
let owner = key_of(owner_b58)?;
@@ -758,6 +945,8 @@ pub(crate) async fn on_inbox_invite(app: &tauri::AppHandle, sender: [u8; 32], in
room_secret: None,
roster: BTreeMap::new(),
invited: BTreeMap::new(),
share_listens: false,
last_listen: None,
join_sent_at: 0,
});
rec.name = cap(&inv.name, sp::MAX_NAME_BYTES);
@@ -970,6 +1159,26 @@ pub async fn space_leave(app: tauri::AppHandle, owner: String, space: String) ->
leave(&app, &owner, &space).await
}
#[tauri::command]
pub async fn space_set_share_listens(app: tauri::AppHandle, owner: String, space: String, on: bool) -> Result<SpaceView, String> {
set_share_listens(&app, &owner, &space, on).await
}
/// Called by the player when a track has actually been listened to — not on
/// every skip. Returns how many Spaces heard it, which is 0 whenever nothing
/// is sharing, and that is the normal case.
#[tauri::command]
pub async fn space_report_listen(
app: tauri::AppHandle,
title: String,
artist: String,
album: String,
duration_secs: Option<u32>,
track_id: Option<String>,
) -> Result<u32, String> {
report_listen(&app, ListenBody { title, artist, album, duration_secs, track_id }).await
}
#[cfg(test)]
mod tests {
use super::*;
@@ -987,10 +1196,45 @@ mod tests {
room_secret: secret.map(|s| b58(&s)),
roster: BTreeMap::new(),
invited: BTreeMap::new(),
share_listens: false,
last_listen: None,
join_sent_at: 0,
}
}
/// The window carries two kinds of body now. Chat has always been raw
/// UTF-8, and every line already on the network is that, so the typed one
/// has to be something a typed line can never be.
#[test]
fn a_listen_is_told_apart_from_a_chat_line() {
let l = ListenBody {
title: "Poa Alpina".into(),
artist: "Biosphere".into(),
album: "Substrata".into(),
duration_secs: Some(253),
track_id: None,
};
let bytes = encode_listen(&l).unwrap();
match decode_body(&bytes) {
Some(Body::Listen(got)) => {
assert_eq!(got.title, "Poa Alpina");
assert_eq!(got.duration_secs, Some(253));
}
_ => panic!("a listen did not come back as one"),
}
// Everything that is not prefixed stays the chat it was, including a
// line that talks about the magic or looks like JSON.
for line in ["hello", "SSLISTEN1", r#"{"title":"nice try"}"#, "0SSLISTEN1{}"] {
match decode_body(line.as_bytes()) {
Some(Body::Text(t)) => assert_eq!(t, line),
_ => panic!("{line:?} was not read as chat"),
}
}
// A body sealed to a key we lack never reaches here, and a truncated
// one is not a listen we can show.
assert!(matches!(decode_body(&bytes[..bytes.len() - 4]), None));
}
/// Which key we wear where. An owner is their persona — the Space's
/// address is derived from it, so there is nothing to hide there — and a
/// member is the key minted for that Space and no other.
+39
View File
@@ -367,6 +367,45 @@
void api.smtcMetadata(title, artist, album, art);
});
/* A listen for any Space that asked for one (src-tauri/src/spaces.rs —
which rooms hear it is that side's business, since the ledger knows and
this bar does not). Reported from here for the same reason the OS card
is: `metaParts` is where a track's artist and album are reconciled, and
deriving them a second time would be a second set of rules.
Once per track, and only after it has actually been LISTENED to — 30s,
or halfway for anything shorter. A skip says nothing about taste, and
the Space's window is shared with its chat, so a room that posted every
press would push its own conversation out inside a day. */
const LISTEN_AFTER = 30;
let listened = "";
$effect(() => {
const title = player.track?.title ?? "";
const pos = player.position ?? 0;
if (!title) return;
const [artist, album] = metaParts;
// The track, not the render. Coming back to a song after playing
// something else changes this key twice, so it reports again — while
// seeking around inside one play never does. This lives in memory, so
// a reload with something already past the threshold reports it again;
// the repeat is dropped on the other side, which is the only place
// that knows what was last said in each room.
const key = `${player.track?.path ?? ""}|${title}|${artist}`;
if (key === listened) return;
const enough = Math.min(LISTEN_AFTER, (player.duration ?? LISTEN_AFTER * 2) / 2);
if (pos < enough) return;
listened = key;
void api
// No catalog id: `player.track` carries what the bar needs to draw
// itself and nothing more. Artist and title are what a listen is
// found by anyway, the same pair `slsk_hint` carries.
.spaceReportListen(title, artist, album, player.duration ? Math.round(player.duration) : null, null)
.catch(() => {
// A Space that cannot be posted to is not this bar's problem;
// the backend already logged which one and why.
});
});
// Waveform canvas + hover state.
let waveWrap = $state(null);
let waveCanvas = $state(null);
+3
View File
@@ -184,6 +184,9 @@ export const spaceInvite = (owner, space, personaKey) => invoke("space_invite",
export const spaceRemoveMember = (owner, space, personaKey) => invoke("space_remove_member", { owner, space, personaKey });
export const spaceSetPinned = (owner, space, playlistOwner, playlistUuid) => invoke("space_set_pinned", { owner, space, playlistOwner, playlistUuid });
export const spaceLeave = (owner, space) => invoke("space_leave", { owner, space });
export const spaceSetShareListens = (owner, space, on) => invoke("space_set_share_listens", { owner, space, on });
export const spaceReportListen = (title, artist, album, durationSecs, trackId) =>
invoke("space_report_listen", { title, artist, album, durationSecs, trackId });
export const spaceBoard = (owner, space) => invoke("space_board", { owner, space });
export const spacePropose = (owner, space, base, ops, note) => invoke("space_propose", { owner, space, base, ops, note });
export const spaceDecide = (owner, space, proposal, accept, reason) => invoke("space_decide", { owner, space, proposal, accept, reason });
+5 -1
View File
@@ -654,6 +654,10 @@ export const pinToSpace = (owner, space, playlistOwner, playlistUuid) =>
run("space:pin", async () => {
community.space = await api.spaceSetPinned(owner, space, playlistOwner, playlistUuid);
});
export const setShareListens = (owner, space, on) =>
run("space:listens", async () => {
community.space = await api.spaceSetShareListens(owner, space, on);
});
export const leaveSpace = (owner, space) =>
run("space:leave", async () => {
await api.spaceLeave(owner, space);
@@ -726,4 +730,4 @@ export const decideProposal = (owner, space, proposal, accept, reason) =>
// Dev handle beside `__soulstream` (state.svelte.js): probes reach the store
// 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.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
if (import.meta.env.DEV) globalThis.__communityActions = { friendRequest, friendRequestByKey, friendAccept, friendDecline, friendRemove, friendForget, checkLink, refreshFriends, sharedPublish, sharedWithdraw, shareSend, sharePreview, shareImport, refreshShares, refreshPublished, publishInbox, checkInbox, refreshInbox, refreshSpaces, openSpace, createSpace, postToSpace, inviteToSpace, removeFromSpace, pinToSpace, leaveSpace, setShareListens, openBoard, proposeToBoard, decideProposal, refreshOverlap, computeOverlap, forgetOverlap, refreshInterests, setInterest };
+153
View File
@@ -22,6 +22,7 @@
downloads,
network,
goToView,
searchNetworkFor,
openChat,
sendDm,
joinRoom,
@@ -90,6 +91,7 @@
removeFromSpace,
pinToSpace,
leaveSpace,
setShareListens,
openBoard,
proposeToBoard,
decideProposal,
@@ -470,6 +472,59 @@
let spaceText = $state("");
let spaceInviteKey = $state("");
let spaceTab = $state("chat");
/* A listen is a point in time, not a state: nothing on the wire says
"still playing". So the room reads one as live for a while and then
lets it go quiet, rather than claiming someone is listening because
their app never got to say they stopped. */
const LISTEN_LIVE_MINS = 15;
// Newest first, which is the opposite of the chat's order: a trail is
// read from the top, a conversation from the bottom.
const listensNewestFirst = $derived([...(sv?.listens ?? [])].sort((a, b) => b.created_at - a.created_at));
const nowPlaying = $derived.by(() => {
const seen = new Map();
const cutoff = Date.now() / 1000 - LISTEN_LIVE_MINS * 60;
for (const l of listensNewestFirst) {
if (!seen.has(l.author)) seen.set(l.author, { ...l, live: l.created_at >= cutoff });
}
// Whoever is playing leads; the rest are the room's most recent, and
// only enough of them to stay a band rather than a second list.
return [...seen.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.created_at - a.created_at).slice(0, 8);
});
// Day separators and run-grouping are decided once, here, so the markup
// stays a list and the rules stay readable.
const trail = $derived.by(() => {
let lastDay = "";
let lastAuthor = "";
return listensNewestFirst.map((l) => {
const d = fmtWhen(l.created_at * 1000);
const dayLabel = new Date(l.created_at * 1000).toDateString();
const day = dayLabel === lastDay ? null : (lastDay = dayLabel) && (isToday(l.created_at) ? "Today" : d);
const run = !day && l.author === lastAuthor;
lastAuthor = l.author;
return { ...l, day, run };
});
});
const isToday = (secs) => new Date(secs * 1000).toDateString() === new Date().toDateString();
function ago(secs) {
const m = Math.max(0, Math.round(Date.now() / 1000 - secs) / 60);
if (m < 60) return `${Math.max(1, Math.round(m))}m ago`;
const h = m / 60;
return h < 24 ? `${Math.round(h)}h ago` : `${Math.round(h / 24)}d ago`;
}
/* The cover has not been resolved for a track someone else played, and
fetching one per row would be a lot of network for a list. A hue keyed
off the album gives the trail a stable colour per record instead. */
function artWash(seed) {
let h = 0x811c9dc5;
for (const c of String(seed)) { h ^= c.charCodeAt(0); h = Math.imul(h, 0x01000193) >>> 0; }
const hue = h % 360;
return `linear-gradient(140deg,hsl(${hue} 42% 26%),hsl(${(hue + 40) % 360} 38% 16%))`;
}
// The one action a listen affords: go and find it. Artist and title are
// the pair every other dead end in the app searches on, and
// `searchNetworkFor` is that road — it seeds the query and switches the
// pill, so this needs no path of its own.
const findListen = (l) => void searchNetworkFor(`${l.artist} ${l.title}`.trim());
let spaceCreating = $state(false);
const sv = $derived(community.space);
const spaceOwner = $derived(sv?.my_role === "owner");
@@ -1234,6 +1289,7 @@
</div>
<div class="sptabs">
<button class="subtab" class:on={spaceTab === "chat"} onclick={() => (spaceTab = "chat")}>Chat<b>{sv.messages.length}</b></button>
<button class="subtab" class:on={spaceTab === "listening"} onclick={() => (spaceTab = "listening")}>Listening{#if sv.listens.length}<b>{sv.listens.length}</b>{/if}</button>
<button class="subtab" class:on={spaceTab === "members"} onclick={() => (spaceTab = "members")}>Members<b>{sv.members.length}</b></button>
<button class="subtab" class:on={spaceTab === "about"} onclick={() => (spaceTab = "about")}>About</button>
<button class="subtab" class:on={spaceTab === "proposals"} onclick={() => (spaceTab = "proposals")}>Proposals{#if bv && bv.uuid === sv.pinned_uuid && bv.proposals.length}<b>{bv.proposals.length}</b>{/if}</button>
@@ -1267,6 +1323,57 @@
<button class="send" type="submit" disabled={!sv.can_post || !nodeUp || !spaceText.trim() || !!community.busy["space:post"]} title="Post"><Icon name="send" size={15} /></button>
</form>
</div>
{:else if spaceTab === "listening"}
<div class="panel sharebar" style="margin-bottom:12px">
<button class="toggle" class:on={sv.share_listens} aria-pressed={sv.share_listens} title="Share what I play here"
onclick={() => setShareListens(sv.owner, sv.space_id, !sv.share_listens)} disabled={!nodeUp || !!community.busy["space:listens"]}><i></i></button>
<div class="grow">
<div class="ttl">Share what I play in this Space</div>
<p class="hint">Per Space, off until you say so. A track is posted once it has actually played, never on a skip{#if sv.private}, and sealed to this room's epoch key — the same key its messages use{/if}. Leaving takes it with you.</p>
</div>
{#if sv.share_listens}<span class="chip acc">sharing</span>{:else}<span class="chip off">not sharing</span>{/if}
</div>
{#if nowPlaying.length}
<div class="panel" style="margin-bottom:12px">
<h3><Icon name="play" size={13} /> Right now{#if nowPlaying.some((n) => n.live)}<span class="chip ok">{nowPlaying.filter((n) => n.live).length} playing</span>{/if}</h3>
<div class="nowband">
{#each nowPlaying as n (n.author)}
<div class="nowcard" class:quiet={!n.live}>
<Identicon seed={n.author} size={40} presence={n.live ? "online" : null} />
<div class="body">
<div class="who">{n.is_me ? "you" : n.username ? display(n.username) : fpShort(n.author)}
{#if n.live}<span class="bars"><i></i><i></i><i></i></span>{:else}<span class="muted">{ago(n.created_at)}</span>{/if}</div>
<div class="t">{n.title}</div>
<div class="a">{n.artist}</div>
</div>
</div>
{/each}
</div>
<p class="hint" style="margin-top:10px">A card goes quiet after {LISTEN_LIVE_MINS} minutes with nothing new — nobody is reported as listening because the app forgot to say they stopped.</p>
</div>
{/if}
<div class="panel">
<h3><Icon name="disc" size={13} /> The trail<span class="chip off">last {sv.message_cap} kept, with the chat</span></h3>
{#if !sv.listens.length}
<p class="hint" style="padding:18px 0 8px">Nothing played here yet. Turn the switch above on and the next track you finish appears — for everyone in the room, and nobody outside it.</p>
{:else}
<div class="lf">
{#each trail as l (l.id)}
{#if l.day}<div class="daysep"><span>{l.day}</span><span class="rule"></span></div>{/if}
<div class="lr" class:self={l.is_me}>
<span class="tm">{fmtClock(l.created_at * 1000)}</span>
<span class="art" style="background:{artWash(l.album || l.artist || l.title)}">{(l.album || l.artist || l.title).slice(0, 2).toUpperCase()}</span>
<span class="meta"><span class="t">{l.title}</span><span class="a">{l.artist}</span></span>
<span class="who" class:mine={l.is_me}>
{#if l.run}<span class="runpad"></span>{:else}<Identicon seed={l.author} size={22} />{/if}
{#if !l.run}{l.is_me ? "you" : l.username ? display(l.username) : fpShort(l.author)}{/if}
<span class="acts"><button class="ico" title="Find it on Soulseek" onclick={() => findListen(l)}><Icon name="search" size={12} /></button></span>
</span>
</div>
{/each}
</div>
{/if}
</div>
{:else if spaceTab === "members"}
<div class="panel">
<div class="members">
@@ -2153,6 +2260,52 @@
.dec { display: flex; gap: 10px; align-items: center; padding: 7px 0; border-top: 1px solid var(--hair); font-size: 12.5px; }
.dec:first-of-type { border-top: none; }
.dec .rs { flex: 1; }
/* ── the room's listening trail ── */
.sharebar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.sharebar .grow { flex: 1; min-width: 240px; }
.sharebar .ttl { font-size: 12.5px; font-weight: 800; }
.toggle { width: 38px; height: 21px; border-radius: 99px; background: var(--surf-hi); position: relative; flex: none; transition: 0.18s; }
.toggle i { position: absolute; top: 3px; left: 3px; width: 15px; height: 15px; border-radius: 50%; background: var(--text-muted); transition: 0.18s; }
.toggle.on { background: var(--accent); }
.toggle.on i { left: 20px; background: #000; }
.toggle:disabled { opacity: 0.45; cursor: default; }
.nowband { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: 10px; margin-top: 10px; }
.nowcard { display: flex; gap: 11px; align-items: center; padding: 10px 12px; border-radius: 10px; background: var(--surf-hi); border: 1px solid var(--hair); min-width: 0; }
.nowcard .body { min-width: 0; flex: 1; }
.nowcard .who { font-size: 11px; font-weight: 800; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; }
.nowcard .t { font-size: 13px; font-weight: 800; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nowcard .a { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nowcard.quiet { opacity: 0.55; }
.bars { display: inline-flex; align-items: flex-end; gap: 1.5px; height: 9px; }
.bars i { width: 2px; background: var(--ok); border-radius: 1px; animation: eq 900ms ease-in-out infinite alternate; }
.bars i:nth-child(2) { animation-delay: 160ms; }
.bars i:nth-child(3) { animation-delay: 320ms; }
@keyframes eq { from { height: 2px; } to { height: 9px; } }
@media (prefers-reduced-motion: reduce) { .bars i { animation: none; height: 6px; } }
.lf { display: flex; flex-direction: column; gap: 1px; margin-top: 6px; }
/* Wider than the chat's 42px time column: these rows are centred rather
than baseline-aligned, so a locale clock that wraps ("11:49 / AM")
shows as two lines instead of pushing the row. */
.lr { display: grid; grid-template-columns: 58px auto 1fr auto; gap: 0 10px; align-items: center; padding: 0 8px; height: 44px; border-radius: 8px; }
.lr:hover { background: var(--surf-hi); }
.lr.self { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.lr .tm { font-size: 10.5px; color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
.lr .art { width: 32px; height: 32px; border-radius: 5px; flex: none; display: grid; place-items: center; font-size: 9px; font-weight: 900; color: rgba(255, 255, 255, 0.5); overflow: hidden; }
.lr .meta { min-width: 0; display: flex; align-items: baseline; gap: 8px; }
.lr .t { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lr .a { font-size: 12px; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lr .who { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 800; color: var(--text-secondary); }
.lr .who.mine { color: #fff; }
/* A run keeps the name's column without repeating it, so the rows stay
aligned and a person still reads as one block. */
.lr .runpad { width: 22px; flex: none; }
.lr .acts { display: flex; gap: 5px; opacity: 0; }
.lr:hover .acts, .lr:focus-within .acts { opacity: 1; }
.lr .ico { width: 26px; height: 26px; border-radius: 99px; display: grid; place-items: center; background: rgba(255, 255, 255, 0.1); }
.lr .ico:hover { background: rgba(255, 255, 255, 0.2); }
.daysep { display: flex; align-items: center; gap: 10px; padding: 12px 8px 6px; }
.daysep span { font-size: 10px; font-weight: 900; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); }
.daysep .rule { flex: 1; height: 1px; background: var(--hair); }
.sptabs { display: flex; gap: 2px; margin: 12px 0 14px; align-items: center; }
.subtab { padding: 6px 13px; border-radius: 8px; font-size: 12px; font-weight: 800; color: var(--text-secondary); display: inline-flex; gap: 6px; align-items: center; }
.subtab.on { background: var(--surf-hi); color: #fff; }