spaces: a room carries what its members are playing, sealed in the window its chat already uses
This commit is contained in:
1 parent
4ca9ab043b
commit
df06f849e0
6 files changed
+459
-14
No files matched your search
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user