diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 38dc5e9..5a9224a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/spaces.rs b/src-tauri/src/spaces.rs index 1c8c997..ee25527 100644 --- a/src-tauri/src/spaces.rs +++ b/src-tauri/src/spaces.rs @@ -61,6 +61,16 @@ struct SpaceRecord { /// OWNER ONLY: personas we have invited and will accept a join from. #[serde(default)] invited: BTreeMap, + /// 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, } +#[derive(Debug, Serialize)] +pub struct ListenView { + pub id: String, + pub author: String, + pub username: Option, + pub is_me: bool, + pub created_at: u64, + pub epoch: u64, + pub title: String, + pub artist: String, + pub album: String, + pub duration_secs: Option, + pub track_id: Option, +} + #[derive(Debug, Serialize)] pub struct SpaceView { pub id: String, @@ -122,13 +147,67 @@ pub struct SpaceView { pub pinned_owner: Option, pub pinned_uuid: Option, pub messages: Vec, + /// 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, 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, + /// 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, +} + +/// 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, 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 { + 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> { + 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,31 @@ 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( + 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, + 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( @@ -371,11 +479,17 @@ 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, - } + 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) -> 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 { + { + 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 { + 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 { + 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, + track_id: Option, +) -> Result { + 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. diff --git a/src/components/PlayerBar.svelte b/src/components/PlayerBar.svelte index 28b2d07..dce8c4b 100644 --- a/src/components/PlayerBar.svelte +++ b/src/components/PlayerBar.svelte @@ -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); diff --git a/src/lib/api.js b/src/lib/api.js index 4c62fe2..33fb308 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -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 }); diff --git a/src/lib/community.svelte.js b/src/lib/community.svelte.js index f15d1e1..f25c1ec 100644 --- a/src/lib/community.svelte.js +++ b/src/lib/community.svelte.js @@ -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 }; diff --git a/src/views/Community.svelte b/src/views/Community.svelte index c01d869..919f67f 100644 --- a/src/views/Community.svelte +++ b/src/views/Community.svelte @@ -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 @@
+ @@ -1267,6 +1323,57 @@
+ {:else if spaceTab === "listening"} +
+ +
+
Share what I play in this Space
+

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.

+
+ {#if sv.share_listens}sharing{:else}not sharing{/if} +
+ {#if nowPlaying.length} +
+

Right now{#if nowPlaying.some((n) => n.live)}{nowPlaying.filter((n) => n.live).length} playing{/if}

+
+ {#each nowPlaying as n (n.author)} +
+ +
+
{n.is_me ? "you" : n.username ? display(n.username) : fpShort(n.author)} + {#if n.live}{:else}{ago(n.created_at)}{/if}
+
{n.title}
+
{n.artist}
+
+
+ {/each} +
+

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.

+
+ {/if} +
+

The traillast {sv.message_cap} kept, with the chat

+ {#if !sv.listens.length} +

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.

+ {:else} +
+ {#each trail as l (l.id)} + {#if l.day}
{l.day}
{/if} +
+ {fmtClock(l.created_at * 1000)} + {(l.album || l.artist || l.title).slice(0, 2).toUpperCase()} + {l.title}{l.artist} + + {#if l.run}{:else}{/if} + {#if !l.run}{l.is_me ? "you" : l.username ? display(l.username) : fpShort(l.author)}{/if} + + +
+ {/each} +
+ {/if} +
{:else if spaceTab === "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; }