forked from Keelrleek/SoulStream
follow: the artist page's heart becomes a real thing, stored and celebrated
Following an artist now writes a row: followed_artists (artist_id, name, portrait, followed_at) beside the playlists in the library db, reached through follow_artist / unfollow_artist / followed_artists, each emitting soulseek:follows-changed. Re-following refreshes the name and portrait — identity improves as pages are visited and a later portrait is usually the better one — but keeps the original followed_at, so "following since" stays honest. Name and portrait are stored on purpose rather than looked up: the home page's "new releases by artists you follow" can then draw a card from the row alone, with no catalog request per artist. The store is the UI truth and the chip never keeps its own copy. It is optimistic — the chip flips at the click and reverts if the write fails, rather than leaving the screen telling a lie. The chip itself (FB4) leads the stat row and wears the accent as ground with dark text, the Play pill's own trick, so its on-state is legible across the room. The lead slot is a fixed 14x14 in which the +, the equalizer bars and the check swap places, so nothing about the chip's box moves except the word — and the word morphs between its measured widths instead of snapping. Clicking runs the Encore: the + collapses, three bars dance three beats with a note rising per beat, they bow, and the check springs in as "Following" grows into place. The db write and the toast fire at the CLICK; the ceremony is presentation only, so navigating away mid-show still follows. Unfollow is an undo — the shrink morphs, nothing celebrates, and the toast says so. Every effect is template-owned with scoped keyframes. The first build spawned the glow and the notes imperatively and referenced -global- keyframes, which silently did not resolve: the glow ran no animation and sat at full opacity, the notes held opacity 0 and never appeared. The bars worked throughout for exactly the reason the others did not. The row around it is shorter too, at Kyle's call: the library and listening chips (progress meter, the disk popover, format, hours, last played, top track, genre) come out, leaving the identity trio and the socials. With nothing left to shed, the socials' scroll-driven fade and their layout drop at the park boundary come out with them, and the divider's clear space moves from the follow heart by name to whichever chip now closes the run. The JS that fed the removed chips stays — it is cheap, local, and the numbers still have a home coming.
This commit is contained in:
1 parent
5786269592
commit
520e2ad4e4
8 files changed
+763
-197
No files matched your search
@@ -19,6 +19,7 @@
|
||||
import CatalogSearch from "./views/CatalogSearch.svelte";
|
||||
import CatalogArtist from "./views/CatalogArtist.svelte";
|
||||
import CatalogAlbum from "./views/CatalogAlbum.svelte";
|
||||
import FollowToast from "./components/FollowToast.svelte";
|
||||
import {
|
||||
ui,
|
||||
initEvents,
|
||||
@@ -97,6 +98,7 @@
|
||||
</main>
|
||||
<NowPlaying />
|
||||
<PlayerBar />
|
||||
<FollowToast />
|
||||
<PreviewOverlay />
|
||||
<ArtworkViewer />
|
||||
<LoginModal />
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<script>
|
||||
// The follow confirmation — the approved T2: a dark glass pill above the
|
||||
// player bar with the artist's round portrait riding in it. "You're now
|
||||
// following {artist}" on follow; unfollow gets the same pill saying so,
|
||||
// with no celebration anywhere else (that is the follow chip's rule too).
|
||||
//
|
||||
// Driven by followToast.seq so two follows in a row re-show rather than
|
||||
// the second one landing on a toast that is already up. The element stays
|
||||
// mounted through its exit so the sink-out actually plays — Svelte's
|
||||
// outro would also work, but the mock's approved motion is a pair of CSS
|
||||
// keyframes and this keeps them verbatim.
|
||||
import Icon from "./Icon.svelte";
|
||||
import { followToast } from "../lib/state.svelte.js";
|
||||
|
||||
const HOLD_MS = 2200;
|
||||
const LEAVE_MS = 280;
|
||||
|
||||
let shown = $state(false);
|
||||
let leaving = $state(false);
|
||||
let holdTimer = 0;
|
||||
let leaveTimer = 0;
|
||||
|
||||
$effect(() => {
|
||||
const seq = followToast.seq;
|
||||
if (!seq) return;
|
||||
clearTimeout(holdTimer);
|
||||
clearTimeout(leaveTimer);
|
||||
// Retrigger cleanly: unmount for a frame so the enter animation
|
||||
// replays even when the previous toast is still on screen.
|
||||
shown = false;
|
||||
leaving = false;
|
||||
requestAnimationFrame(() => {
|
||||
shown = true;
|
||||
holdTimer = setTimeout(() => {
|
||||
leaving = true;
|
||||
leaveTimer = setTimeout(() => {
|
||||
shown = false;
|
||||
leaving = false;
|
||||
}, LEAVE_MS);
|
||||
}, HOLD_MS);
|
||||
});
|
||||
return () => {
|
||||
clearTimeout(holdTimer);
|
||||
clearTimeout(leaveTimer);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if shown}
|
||||
<div class="layer" aria-live="polite">
|
||||
<div class="toast" class:leaving>
|
||||
{#if followToast.portrait}
|
||||
<span class="art"><img src={followToast.portrait} alt="" draggable="false" /></span>
|
||||
{:else}
|
||||
<span class="check"><Icon name="check" size={15} /></span>
|
||||
{/if}
|
||||
{#if followToast.on}
|
||||
<span>You're now following <b>{followToast.name}</b></span>
|
||||
{:else}
|
||||
<span>Unfollowed <b>{followToast.name}</b></span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Above the player bar, centred on the window — the player's own height
|
||||
var keeps it clear of the transport in both collapsed and expanded
|
||||
states. The offset is three named parts:
|
||||
|
||||
var(--player-height) the bar itself (62 rest, 90 expanded)
|
||||
8px the app shell's bottom gutter under the bar
|
||||
26px air between the bar's top edge and the toast —
|
||||
at the mock's 16px total the pill hovered 8px
|
||||
off the bar and read as touching the window
|
||||
bottom in a real-height window (measured 78px
|
||||
up in a 977px viewport).
|
||||
|
||||
pointer-events none: a confirmation must never intercept a click aimed
|
||||
at the lane under it. */
|
||||
.layer {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--player-height, 62px) + 8px + 26px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 80;
|
||||
pointer-events: none;
|
||||
}
|
||||
.toast {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 16px;
|
||||
border-radius: 99px;
|
||||
background: rgba(22, 23, 28, 0.94);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.55);
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
animation: toast-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
.toast.leaving {
|
||||
animation: toast-out 260ms ease both;
|
||||
}
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
@keyframes toast-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
}
|
||||
}
|
||||
.art {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
flex: none;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.art img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.check {
|
||||
display: inline-flex;
|
||||
color: var(--accent);
|
||||
}
|
||||
b {
|
||||
font-weight: 800;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast,
|
||||
.toast.leaving {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -105,6 +105,12 @@ export const recentAlbums = (limit, minSecs) =>
|
||||
invoke("recent_albums", { limit: limit ?? null, minSecs: minSecs ?? null });
|
||||
export const addPlaySeconds = (playId, secs) => invoke("add_play_seconds", { playId, secs });
|
||||
export const artistPlayStats = (artist) => invoke("artist_play_stats", { artist });
|
||||
// Followed artists — the home redesign's future "new releases by artists you
|
||||
// follow" reads the same list, which is why the rows carry name and portrait.
|
||||
export const followedArtists = () => invoke("followed_artists");
|
||||
export const followArtist = (artistId, name, portrait) =>
|
||||
invoke("follow_artist", { artistId, name, portrait: portrait ?? null });
|
||||
export const unfollowArtist = (artistId) => invoke("unfollow_artist", { artistId });
|
||||
// The WebView blocks target="_blank"; external links open via the backend.
|
||||
export const openExternal = (url) => invoke("open_external", { url });
|
||||
export const albumArt = (path) => invoke("album_art", { path });
|
||||
|
||||
@@ -83,6 +83,61 @@ function writePanels() {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- followed artists
|
||||
* Persisted in the library db (followed_artists — id, name, portrait,
|
||||
* followed_at) so the future home redesign can build "new releases by artists
|
||||
* you follow" from this list alone. The UI truth is this store; the artist
|
||||
* page's chip reads it and never keeps its own copy.
|
||||
*/
|
||||
export const follows = $state({
|
||||
loaded: false,
|
||||
/** artistId -> true. A plain object, not a Set: $state proxies object
|
||||
* property changes, which is exactly the granularity the chip needs. */
|
||||
ids: {},
|
||||
});
|
||||
|
||||
export async function loadFollows() {
|
||||
if (follows.loaded) return;
|
||||
try {
|
||||
const list = await api.followedArtists();
|
||||
follows.ids = Object.fromEntries(list.map((f) => [f.artist_id, true]));
|
||||
follows.loaded = true;
|
||||
} catch {
|
||||
// Backend unavailable — the chip renders unfollowed and stays usable;
|
||||
// the next call retries because `loaded` never flipped.
|
||||
}
|
||||
}
|
||||
|
||||
export const isFollowed = (artistId) => artistId != null && !!follows.ids[artistId];
|
||||
|
||||
/** Optimistic: the chip flips immediately, the db write follows, and a write
|
||||
* failure flips it back rather than leaving the UI lying. */
|
||||
export async function setFollowed(artist, on) {
|
||||
const id = artist?.id;
|
||||
if (id == null) return;
|
||||
const prev = !!follows.ids[id];
|
||||
if (on) follows.ids[id] = true;
|
||||
else delete follows.ids[id];
|
||||
try {
|
||||
if (on) await api.followArtist(id, artist.name ?? "", artist.picture ?? null);
|
||||
else await api.unfollowArtist(id);
|
||||
} catch {
|
||||
if (prev) follows.ids[id] = true;
|
||||
else delete follows.ids[id];
|
||||
}
|
||||
}
|
||||
|
||||
/** The bottom toast (approved T2: portrait pill). `seq` is the trigger — every
|
||||
* announcement bumps it, so following twice in a row still re-shows. */
|
||||
export const followToast = $state({ seq: 0, on: false, name: "", portrait: null });
|
||||
|
||||
export function announceFollow(on, artist) {
|
||||
followToast.seq += 1;
|
||||
followToast.on = on;
|
||||
followToast.name = artist?.name ?? "";
|
||||
followToast.portrait = artist?.picture ?? null;
|
||||
}
|
||||
|
||||
export const ui = $state({
|
||||
view: "home",
|
||||
albumId: null, // selected album in Library view
|
||||
|
||||
+430
-197
@@ -30,6 +30,10 @@
|
||||
albumDisambiguator,
|
||||
openCatalogAlbum,
|
||||
openAlbumFromPreviewItem,
|
||||
loadFollows,
|
||||
isFollowed,
|
||||
setFollowed,
|
||||
announceFollow,
|
||||
} from "../lib/state.svelte.js";
|
||||
import * as api from "../lib/api.js";
|
||||
import { accentFromImage } from "../lib/color.js";
|
||||
@@ -4316,18 +4320,134 @@
|
||||
soundcloud: { icon: "brand-soundcloud", label: "SoundCloud", rgb: "208,162,132" },
|
||||
};
|
||||
|
||||
// TODO(follow): visual-only for now — no persistence. Resets when the
|
||||
// artist changes so a heart never carries over to the next page.
|
||||
let followed = $state(false);
|
||||
let followedArtistId = null;
|
||||
/* ---- the follow chip (approved 2026-08-04: FB4, leading the row, the
|
||||
* Encore choreography, the portrait toast) --------------------------------
|
||||
*
|
||||
* Truth lives in the `follows` store — persisted in the library db, read
|
||||
* by the future home page — and this page keeps only the VISUAL state,
|
||||
* which deliberately lags it: on follow the db write and the toast fire at
|
||||
* the click, but the chip walks through the ceremony (+ collapses → three
|
||||
* bars dance → the check springs in with the label) and only wears its
|
||||
* on-state at the check's arrival. Unfollow is an undo and skips the show.
|
||||
*
|
||||
* fbShown/fbStage drive the markup through Svelte; the ceremony flips them
|
||||
* on timers. Only the floating notes and the glow are imperative spawns
|
||||
* (they are transient siblings the template must not own), which is why
|
||||
* their selectors are :global below — Svelte prunes classes it cannot see
|
||||
* in the markup, and a pruned rule simply never renders (the .namebox
|
||||
* lesson).
|
||||
*/
|
||||
let fbEl = $state(null);
|
||||
let fbShown = $state(false);
|
||||
let fbStage = $state("idle"); // "idle" | "bars"
|
||||
let fbJustLanded = $state(false); // gates the check/label entrances to the ceremony
|
||||
// $state because the template reads it (the + wears lead-out while the
|
||||
// ceremony runs) — a plain let never re-renders in runes mode.
|
||||
let fbCeremony = $state(false);
|
||||
let fbTimers = [];
|
||||
|
||||
$effect(() => {
|
||||
const id = page?.artist.id ?? null;
|
||||
if (id !== followedArtistId) {
|
||||
followedArtistId = id;
|
||||
followed = false;
|
||||
void loadFollows();
|
||||
const id = page?.artist?.id;
|
||||
const on = id != null && isFollowed(id);
|
||||
if (!fbCeremony) {
|
||||
fbShown = on;
|
||||
fbStage = "idle";
|
||||
fbJustLanded = false;
|
||||
}
|
||||
});
|
||||
|
||||
// A navigation mid-ceremony must not leak timers into the next artist.
|
||||
$effect(() => {
|
||||
void page?.artist?.id;
|
||||
return () => {
|
||||
fbTimers.forEach(clearTimeout);
|
||||
fbTimers = [];
|
||||
fbCeremony = false;
|
||||
};
|
||||
});
|
||||
|
||||
/** Follow/Following width change played, never snapped: the label's width
|
||||
* is animated between its measured sizes and the chip grows with it. */
|
||||
function fbMorphLabel(mutate) {
|
||||
const label = fbEl?.querySelector(".fb-label");
|
||||
const w0 = label ? label.getBoundingClientRect().width : null;
|
||||
mutate();
|
||||
if (label == null || w0 == null || reducedMotion()) return;
|
||||
void tick().then(() => {
|
||||
const now = fbEl?.querySelector(".fb-label");
|
||||
if (!now) return;
|
||||
const w1 = now.getBoundingClientRect().width;
|
||||
if (Math.abs(w1 - w0) > 1) {
|
||||
now.animate(
|
||||
[{ width: `${w0}px` }, { width: `${w1}px` }],
|
||||
{ duration: 240, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** The ceremony's floating notes — rendered by the template while
|
||||
* fbCeremony holds, positioned from the lead slot at click time. Their
|
||||
* animations end holding opacity 0 (fill: both) well before the 1800ms
|
||||
* teardown unmounts them, so nothing ever pops off mid-fade. */
|
||||
let fbNotes = $state([]);
|
||||
|
||||
function toggleFollow() {
|
||||
const artist = page?.artist;
|
||||
if (!artist || fbCeremony) return;
|
||||
if (fbShown) {
|
||||
// Unfollow: an undo. The shrink still morphs; nothing celebrates.
|
||||
void setFollowed(artist, false);
|
||||
announceFollow(false, artist);
|
||||
fbMorphLabel(() => {
|
||||
fbShown = false;
|
||||
fbJustLanded = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Persist and toast at the CLICK — the ceremony is presentation, and a
|
||||
// user who navigates away mid-show must still have followed.
|
||||
void setFollowed(artist, true);
|
||||
announceFollow(true, artist);
|
||||
if (reducedMotion()) {
|
||||
fbMorphLabel(() => (fbShown = true));
|
||||
return;
|
||||
}
|
||||
fbCeremony = true;
|
||||
// One note per beat, rising from the bars: positions taken from the
|
||||
// lead slot NOW, rendered by the template for as long as the ceremony
|
||||
// holds. Their flights end at 1710ms (delay 760 + 950) holding opacity
|
||||
// 0, so the 1800ms teardown only ever removes what has already faded —
|
||||
// the mock paid twice to learn that removing a note any earlier
|
||||
// cancels its animation mid-fade.
|
||||
const lead = fbEl.querySelector(".fb-lead");
|
||||
const lr = lead.getBoundingClientRect();
|
||||
const br = fbEl.getBoundingClientRect();
|
||||
const lx = lr.left - br.left + lr.width / 2;
|
||||
fbNotes = [0, 1, 2].map((i) => ({
|
||||
cx: lx + (i - 1) * 4,
|
||||
sway: i % 2 ? -7 : 7,
|
||||
delay: 240 + i * 260,
|
||||
glyph: i === 1 ? "♫" : "♪",
|
||||
}));
|
||||
fbTimers = [
|
||||
setTimeout(() => (fbStage = "bars"), 170),
|
||||
setTimeout(() => {
|
||||
fbJustLanded = true;
|
||||
fbMorphLabel(() => {
|
||||
fbStage = "idle";
|
||||
fbShown = true;
|
||||
});
|
||||
}, 1070),
|
||||
setTimeout(() => {
|
||||
fbNotes = [];
|
||||
fbJustLanded = false;
|
||||
fbCeremony = false;
|
||||
}, 1800),
|
||||
];
|
||||
}
|
||||
|
||||
// First→latest release year of the released discography ("years active").
|
||||
// A release within the last two calendar years reads as "present".
|
||||
const activeYears = $derived.by(() => {
|
||||
@@ -4632,73 +4752,79 @@
|
||||
its stat is empty rather than showing a zero. -->
|
||||
{#if !page.art_preview}
|
||||
<div class="stat-chips">
|
||||
{#if releasedCount > 0}
|
||||
<span class="chip"><Icon name="disc" size={13} /> {releasedCount} releases</span>
|
||||
{/if}
|
||||
{#if page.artist.fans}
|
||||
<span class="chip"><Icon name="users" size={13} /> {fmtFans(page.artist.fans)}</span>
|
||||
{/if}
|
||||
{#if activeYears}
|
||||
<span class="chip" title="Years active"><Icon name="calendar" size={13} /> {activeYears}</span>
|
||||
{/if}
|
||||
{#if libraryOwnedCount > 0 && releasedCount > 0}
|
||||
<span class="chip" title="{libraryOwnedCount} of {releasedCount} releases in your library">
|
||||
<Icon name="library" size={13} /> {libraryOwnedCount}/{releasedCount}
|
||||
<span class="chip-bar"><span
|
||||
class="chip-fill"
|
||||
style="width: {Math.min(100, Math.round((libraryOwnedCount / releasedCount) * 100))}%"
|
||||
></span></span>
|
||||
</span>
|
||||
{/if}
|
||||
{#if artistDisk.bytes > 0}
|
||||
<span class="chip has-pop">
|
||||
<Icon name="hard-drive" size={13} /> {fmtBytes(artistDisk.bytes)}
|
||||
{#if artistDisk.perAlbum.length}
|
||||
<span class="chip-pop" role="tooltip">
|
||||
{#each artistDisk.perAlbum as row (row.title)}
|
||||
<span class="pop-row">
|
||||
<span class="pop-title">{row.title}</span>
|
||||
<span class="pop-val">{fmtBytes(row.bytes)}</span>
|
||||
</span>
|
||||
{/each}
|
||||
<!-- LEADS the row (Kyle's seat pick). The lead
|
||||
slot is fixed 14x14 so the + , the bars and
|
||||
the check swap inside it without the chip
|
||||
ever changing size for any reason except
|
||||
the word itself — and that change morphs
|
||||
(fbMorphLabel), never snaps. -->
|
||||
<button
|
||||
class="chip fb"
|
||||
class:on={fbShown}
|
||||
bind:this={fbEl}
|
||||
title={fbShown ? "Unfollow artist" : "Follow artist"}
|
||||
aria-pressed={fbShown}
|
||||
onclick={toggleFollow}
|
||||
>
|
||||
<span class="fb-lead">
|
||||
{#if fbStage === "bars"}
|
||||
<span class="lead-eq" aria-hidden="true">
|
||||
<b style="--delay:0ms"></b><b style="--delay:90ms"
|
||||
></b><b style="--delay:180ms"></b>
|
||||
</span>
|
||||
{:else if fbShown}
|
||||
<span class="lead-glyph" class:lead-in={fbJustLanded}>
|
||||
<Icon name="check" size={13} strokeWidth={3} />
|
||||
</span>
|
||||
{:else}
|
||||
<span class="lead-glyph" class:lead-out={fbCeremony}>
|
||||
<Icon name="plus" size={13} strokeWidth={3} />
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
{#if artistDisk.quality}
|
||||
<span class="chip" title="Dominant format of your saved tracks">
|
||||
<Icon name="music" size={13} /> {artistDisk.quality}
|
||||
<span class="fb-label" class:label-in={fbJustLanded}>
|
||||
{fbShown ? "Following" : "Follow"}
|
||||
</span>
|
||||
<!-- Template-owned, like the bars — NOT
|
||||
spawned. The spawned version routed
|
||||
through -global- keyframes and the
|
||||
reference never resolved: the glow sat
|
||||
at full opacity and the notes at zero.
|
||||
Svelte's fine-grained updates leave
|
||||
these siblings alone when the label
|
||||
and lead swap at the check moment, so
|
||||
the detach-restart trap does not
|
||||
apply here either. -->
|
||||
{#if fbCeremony}
|
||||
<span class="fb-glow" aria-hidden="true"></span>
|
||||
{#each fbNotes as n (n.delay)}
|
||||
<span
|
||||
class="fb-note"
|
||||
aria-hidden="true"
|
||||
style="--cx:{n.cx}px; --sway:{n.sway}px; --delay:{n.delay}ms"
|
||||
>{n.glyph}</span>
|
||||
{/each}
|
||||
{/if}
|
||||
</button>
|
||||
{#if releasedCount > 0}
|
||||
<span class="chip"><Icon name="disc" size={13} strokeWidth={3} /> {releasedCount} releases</span>
|
||||
{/if}
|
||||
{#if playStats.seconds >= 60}
|
||||
<span class="chip" title="Total listening time">
|
||||
<Icon name="clock" size={13} /> {fmtPlayTime(playStats.seconds)}
|
||||
</span>
|
||||
{#if page.artist.fans}
|
||||
<span class="chip"><Icon name="users" size={13} strokeWidth={3} /> {fmtFans(page.artist.fans)}</span>
|
||||
{/if}
|
||||
{#if playStats.last}
|
||||
<span class="chip" title="Last played">
|
||||
<Icon name="history" size={13} /> {fmtLastPlayed(playStats.last)}
|
||||
</span>
|
||||
{#if activeYears}
|
||||
<span class="chip" title="Years active"><Icon name="calendar" size={13} strokeWidth={3} /> {activeYears}</span>
|
||||
{/if}
|
||||
{#if playStats.topTitle && playStats.topCount >= 2}
|
||||
<span class="chip" title="Your most played track">
|
||||
<Icon name="trophy" size={13} /> {playStats.topTitle} · {playStats.topCount}×
|
||||
</span>
|
||||
{/if}
|
||||
{#if page.top_genre}
|
||||
<span class="chip" title="Top genre">
|
||||
<Icon name="tag" size={13} /> {page.top_genre}
|
||||
</span>
|
||||
{/if}
|
||||
<!-- TODO(follow): visual toggle only — persistence
|
||||
and the followed-artists feature come later. -->
|
||||
<button
|
||||
class="chip follow"
|
||||
class:on={followed}
|
||||
title={followed ? "Unfollow artist" : "Follow artist"}
|
||||
aria-pressed={followed}
|
||||
onclick={() => (followed = !followed)}
|
||||
><Icon name="heart" size={14} fill={followed} /></button>
|
||||
<!-- The library/listening chips that used to
|
||||
follow (progress, disk + popover, format,
|
||||
hours, last played, top track, genre) and
|
||||
the follow heart were removed 2026-08-04 at
|
||||
Kyle's request — the identity trio above
|
||||
and the socials below are the row now. The
|
||||
JS that fed them (playStats, artistDisk,
|
||||
libraryOwnedCount, followed) is left in
|
||||
place: it is cheap, local, and the follow
|
||||
feature is still planned. -->
|
||||
{#if page.socials?.length}
|
||||
<!-- ONE flex item, not a run of siblings:
|
||||
the whole social block travels to the
|
||||
@@ -4716,7 +4842,12 @@
|
||||
title={meta.label}
|
||||
style="--soc: {meta.rgb}"
|
||||
onclick={(e) => { e.preventDefault(); api.openExternal(link.url); }}
|
||||
><Icon name={meta.icon} size={16} fill={meta.solid ?? false} /></a>
|
||||
><Icon
|
||||
name={meta.icon}
|
||||
size={16}
|
||||
strokeWidth={2.6}
|
||||
fill={meta.solid ?? false}
|
||||
/></a>
|
||||
{/each}
|
||||
</span>
|
||||
{/if}
|
||||
@@ -5656,9 +5787,10 @@
|
||||
transform: scale(calc(1 - var(--p) * (1 - var(--mark-end))));
|
||||
}
|
||||
|
||||
.stat-chips > .social-set {
|
||||
opacity: calc(1 - var(--p) * 2.4);
|
||||
}
|
||||
/* The social block used to fade here (1 - 2.4p) and leave at the park
|
||||
boundary. It STAYS now — Kyle's call, 2026-08-04: the stat chips
|
||||
around it went instead, and the socials ride the parked row exactly
|
||||
as they sit at rest. */
|
||||
|
||||
.preview-count {
|
||||
opacity: calc(1 - var(--p) * 2.4);
|
||||
@@ -5866,19 +5998,10 @@
|
||||
animation-range: 0px var(--travel, 999999px);
|
||||
}
|
||||
|
||||
/* The social block's fade — over --fade-span (= travel / 2.4, its OWN
|
||||
sentinel from measure()), so opacity = 1 - 2.4p with fill-both
|
||||
holding 0 past p = 1/2.4 exactly where the calc clamps, and a
|
||||
degenerate page (999999px sentinel) stays at opacity 1 at any
|
||||
scrollTop, exactly as the pinned p keeps it today. */
|
||||
.stat-chips > .social-set {
|
||||
animation-name: extras-fade-ride;
|
||||
animation-duration: auto;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: both;
|
||||
animation-timeline: --artist-pane;
|
||||
animation-range: 0px var(--fade-span, 999999px);
|
||||
}
|
||||
/* The social block's scroll-driven fade lived here, over --fade-span
|
||||
(still written by measure() — the badge below rides the same
|
||||
window). Removed 2026-08-04: the socials now stay through the park,
|
||||
as they sit at rest. */
|
||||
|
||||
/* The badge: same fade window, plus a counter-scale — the parent
|
||||
.hero-preview now shrinks by transform, which the badge (16px at
|
||||
@@ -6924,29 +7047,14 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* EVERY STAT CHIP survives into the banner now; only the social links go.
|
||||
They used to be shed by count — `:nth-child(n + 4)` — which knows
|
||||
nothing about where the row's lines break: on a three-line row it
|
||||
deleted the genre chip and the heart from BESIDE years-active, leaving
|
||||
it alone on a half-empty line. Cutting at the social block instead is a
|
||||
structural boundary, so a line either survives whole or leaves whole.
|
||||
The opacity ramp lives per-branch (fallback: the live calc;
|
||||
scroll-driven: extras-fade-ride); the hit gate stays here in base for
|
||||
both — pointer-events is not animatable, so apply() keeps writing
|
||||
--extra-hit (boundary-only on the supported path). Redundant with the
|
||||
layout collapse below, and kept: it needs no attribute to have landed. */
|
||||
.stat-chips > .social-set {
|
||||
pointer-events: var(--extra-hit, auto);
|
||||
}
|
||||
|
||||
/* ...and past that same boundary the block leaves LAYOUT too, so its line
|
||||
goes with it. Fading alone kept the line, and the row stayed two lines
|
||||
tall with nothing in the lower one. `display` is not animatable, but by
|
||||
0.42 the block is already at opacity 0, so nothing pops — and with the
|
||||
row anchored flex-start, nothing above it moves either. */
|
||||
.artist-page[data-park="1"] .stat-chips > .social-set {
|
||||
display: none;
|
||||
}
|
||||
/* THE WHOLE ROW survives into the banner now — the identity chips AND the
|
||||
social links. The socials used to fade (per-branch ramps) and leave
|
||||
layout at the 0.42 park boundary; all four of those mechanisms came out
|
||||
on 2026-08-04 when the library/listening chips were removed instead —
|
||||
with the row down to three short chips plus the socials, there is
|
||||
nothing left that needs shedding. apply() still writes --extra-hit and
|
||||
data-park; they are simply unconsumed by the socials now (the badge
|
||||
keeps its own ride). */
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
@@ -6963,106 +7071,228 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 0.45 was most of why these read as "thin": a 13px glyph at 45% white on
|
||||
the hero's dark tail has almost no edge left to see, whatever its stroke
|
||||
is. Lifted to 0.72 — still quieter than the chip's own 0.8 text, so the
|
||||
icon stays subordinate to the number beside it. */
|
||||
.chip :global(.icon) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
/* Mini collection meter inside the library chip — the Steam-achievements
|
||||
analog: how much of the discography is saved locally. */
|
||||
.chip-bar {
|
||||
width: 44px;
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
overflow: hidden;
|
||||
}
|
||||
/* The library-progress meter, the disk chip's per-album popover and the
|
||||
follow heart's rules lived here; their chips were removed 2026-08-04
|
||||
(Kyle's call) and Svelte treats an unused selector as deleted anyway —
|
||||
a warning per rule, and a wall of those is where a real one hides. */
|
||||
|
||||
.chip-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* Hover popover on the disk chip: per-album storage breakdown. Pure CSS
|
||||
(opens on hover/focus), anchored above the chip so it never collides
|
||||
with the Popular list below. */
|
||||
.chip.has-pop {
|
||||
/* ---- the follow chip (FB4 · Encore · leads the row) -------------------
|
||||
Chip metrics come from .chip; only the action-ness is added here. The
|
||||
on-state is the accent as ground with dark text — the Play pill's own
|
||||
trick — which is what made FB4 the pick: ON is visible from across the
|
||||
room. */
|
||||
.chip.fb {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chip-pop {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + 8px);
|
||||
z-index: 30;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 240px;
|
||||
max-width: 340px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
background: rgba(14, 15, 18, 0.92);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 140ms ease, visibility 140ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.chip.has-pop:hover .chip-pop,
|
||||
.chip.has-pop:focus-within .chip-pop {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.pop-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pop-title {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pop-val {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Follow heart: a square chip-styled button closing the row. Fills with
|
||||
the accent when active. Visual-only until the follow feature lands. */
|
||||
.chip.follow {
|
||||
width: 27px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chip.follow:hover {
|
||||
.chip.fb:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chip.follow.on {
|
||||
.chip.fb.on {
|
||||
background: var(--accent);
|
||||
border-color: transparent;
|
||||
color: #0a0a0c;
|
||||
font-weight: 700;
|
||||
}
|
||||
.chip.fb.on:hover {
|
||||
filter: brightness(1.08);
|
||||
color: #0a0a0c;
|
||||
}
|
||||
/* The fixed slot the whole ceremony happens in: the +, the bars and the
|
||||
check swap INSIDE it, so nothing about the chip's box ever moves except
|
||||
the word — and the word morphs (fbMorphLabel), never snaps. */
|
||||
.fb-lead {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex: none;
|
||||
}
|
||||
.lead-glyph {
|
||||
display: inline-flex;
|
||||
}
|
||||
.lead-glyph.lead-out {
|
||||
animation: lead-plus-out 180ms cubic-bezier(0.5, 0, 0.8, 0.5) both;
|
||||
}
|
||||
@keyframes lead-plus-out {
|
||||
to {
|
||||
transform: rotate(100deg) scale(0.15);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.lead-eq {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 13px;
|
||||
}
|
||||
/* height: 100% is load-bearing — a bare b has no intrinsic height, and
|
||||
without it the bars render 3px wide and zero tall: present, invisible
|
||||
(the mock shipped exactly that bug). */
|
||||
.lead-eq b {
|
||||
width: 3px;
|
||||
height: 100%;
|
||||
border-radius: 1px;
|
||||
background: var(--accent);
|
||||
transform-origin: bottom;
|
||||
animation: lead-eq-dance 880ms ease-in-out var(--delay) both;
|
||||
}
|
||||
/* Rise, three uneven beats, and a bow — the bars END low, handing the
|
||||
slot to the check instead of being cut off mid-jump. */
|
||||
@keyframes lead-eq-dance {
|
||||
0% {
|
||||
transform: scaleY(0.12);
|
||||
}
|
||||
12% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
26% {
|
||||
transform: scaleY(0.35);
|
||||
}
|
||||
40% {
|
||||
transform: scaleY(0.92);
|
||||
}
|
||||
54% {
|
||||
transform: scaleY(0.28);
|
||||
}
|
||||
68% {
|
||||
transform: scaleY(0.82);
|
||||
}
|
||||
82% {
|
||||
transform: scaleY(0.3);
|
||||
}
|
||||
100% {
|
||||
transform: scaleY(0.14);
|
||||
}
|
||||
}
|
||||
.lead-glyph.lead-in {
|
||||
animation: lead-check-in 340ms cubic-bezier(0.34, 1.5, 0.6, 1) both;
|
||||
}
|
||||
@keyframes lead-check-in {
|
||||
0% {
|
||||
transform: scale(0) rotate(-45deg);
|
||||
opacity: 0;
|
||||
}
|
||||
55% {
|
||||
transform: scale(1.28) rotate(6deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
/* The label is the chip's only variable-width part; overflow hidden lets
|
||||
fbMorphLabel reveal the longer word by animating THIS box's width. */
|
||||
.fb-label {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fb-label.label-in {
|
||||
animation: label-in 260ms ease 40ms both;
|
||||
}
|
||||
@keyframes label-in {
|
||||
from {
|
||||
opacity: 0.25;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
/* The + and the check follow the CHIP's colour, exactly as the mock's raw
|
||||
SVGs did by inheriting currentColor: 0.8 white at rest, #0a0a0c on the
|
||||
FB4 fill. Without this the page's own `.chip .icon` wash (0.45 white)
|
||||
applied — a pale check floating on the accent, which is not the card
|
||||
Kyle approved. */
|
||||
.chip.fb :global(.icon) {
|
||||
color: inherit;
|
||||
}
|
||||
/* The glow and the notes are TEMPLATE-owned, like the bars — plain scoped
|
||||
classes, plain scoped keyframes. The first build spawned them
|
||||
imperatively and routed through -global- keyframes whose reference
|
||||
never resolved: the glow ran no animation and sat at FULL opacity (the
|
||||
"random glow"), the notes held their base opacity 0 and never appeared.
|
||||
The bars worked the whole time for exactly this reason — they were
|
||||
template + scoped. */
|
||||
.fb-glow {
|
||||
position: absolute;
|
||||
inset: -10px -16px;
|
||||
border-radius: 99px;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
closest-side,
|
||||
color-mix(in srgb, var(--accent) 55%, transparent),
|
||||
transparent 72%
|
||||
);
|
||||
animation: fb-glow-pulse 1500ms ease both;
|
||||
}
|
||||
/* Two pulses: a swell while the bars dance, a brighter beat as the check
|
||||
lands. */
|
||||
@keyframes fb-glow-pulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.32;
|
||||
}
|
||||
55% {
|
||||
opacity: 0.12;
|
||||
}
|
||||
76% {
|
||||
opacity: 0.42;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.fb-note {
|
||||
position: absolute;
|
||||
left: var(--cx);
|
||||
top: -4px;
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
transform: translate(-50%, 0);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
animation: fb-note-float 950ms ease-out var(--delay) both;
|
||||
}
|
||||
@keyframes fb-note-float {
|
||||
0% {
|
||||
transform: translate(-50%, 0) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
15% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
40% {
|
||||
transform: translate(calc(-50% + var(--sway)), -14px) rotate(9deg);
|
||||
}
|
||||
70% {
|
||||
transform: translate(calc(-50% - var(--sway)), -26px) rotate(-8deg);
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: translate(calc(-50% + var(--sway)), -38px) rotate(6deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Socials: ghost icons after the heart — no chip outline, just the pale
|
||||
/* Socials: ghost icons closing the row — no chip outline, just the pale
|
||||
brand-tinted glyph. Hover blooms a soft disc in the same brand colour
|
||||
(--soc is an "r,g,b" triple so the wash can be alpha'd).
|
||||
The whole block is one flex item so it wraps as a unit. No margin of its
|
||||
@@ -7084,7 +7314,10 @@
|
||||
group has wrapped and hiding the divider would hand back the width that
|
||||
caused the wrap, the group would fit again, the divider would return,
|
||||
and the row would flip between one line and two forever. */
|
||||
.stat-chips > .chip.follow:has(+ .social-set) {
|
||||
/* Any chip, not the follow heart by name: the heart was removed with the
|
||||
stat set (2026-08-04), so whichever chip now closes the run — years
|
||||
active, today — carries the divider's clear space instead. */
|
||||
.stat-chips > .chip:has(+ .social-set) {
|
||||
margin-right: 17px;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user