Files
Psychotoxical-psysonic/src/api/bandsintown.ts
T
Frank Stellmacher 06140a490b feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates (#244)
* feat(queue): add Now-Playing Info tab with artist bio, song credits and Bandsintown tour dates

A third tab in the right-side queue panel surfaces context for the
currently playing track:

- Artist card: image + biography from Subsonic getArtistInfo (Last.fm
  "Read more on Last.fm" anchor stripped), with read-more toggle that
  only appears when the bio actually overflows the 4-line clamp.
- Song info: contributor credits from OpenSubsonic contributors[]
  rendered stacked (name prominent, role muted). Section is hidden
  entirely on servers without contributor support, and rows that just
  re-state the main artist under role "artist" are filtered out.
- On tour: optional Bandsintown integration (opt-in, off by default).
  HTTP fetch + JSON parsing happen entirely on the Rust side; the
  frontend wrapper deduplicates concurrent calls and caches results in
  RAM for the session. Limited to 5 events with a "Show N more" toggle.
  When the toggle is off, the section becomes an in-place opt-in card
  with a privacy info-tooltip explaining what data is sent — same
  tooltip is also exposed on the matching toggle in Settings.

Caching: artist info and song detail are memoised by stable IDs across
component remounts, so jumping between tracks of the same album/artist
does not refire the network calls.

Implementation notes:
- Bandsintown app_id "js_app_id" — arbitrary strings (e.g. "psysonic")
  now return HTTP 403 from rest.bandsintown.com; js_app_id is the ID
  Bandsintown's own embeddable widget uses and is broadly accepted.
- Tour items use negative left/right margins so the date badge stays
  visually aligned with the section title while the hover background
  extends slightly past the section edges.
- New i18n namespace nowPlayingInfo across all 8 locales (en, de, fr,
  nl, zh, nb, ru, es) including pluralised "Show N more" forms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(now-playing-info): switch nested flex-columns to block layout to stop content-dependent drift

Repeated reports of the Tour and Song-info sections rendering at
inconsistent x-positions (sometimes left of the section title,
sometimes centred, sometimes correctly aligned) — varying by artist,
sidebar width, and even whether "Show more" was clicked.

Root cause: nested flex-direction: column containers
(.np-info → .np-info-section → .np-info-tour / .np-info-credits) where
the cross-axis (horizontal) sizing on WebKitGTK occasionally inherits
the intrinsic min-content of the longest child. With one
"Naherholungsgebiet/Freizeitgelände Lago Alfredo"-style venue name
that overflows the sidebar, the parent ul gets pushed past 100% width
and the entire layout drifts. min-width: 0 on every level didn't help
because cross-axis stretch in flex-column ignores it for content-driven
overflow.

Fix: collapse all the section/list containers to display: block. Block
layout is content-agnostic — children always render at 100% parent
width, left-aligned, deterministically. Spacing between siblings now
uses the lobotomy selector (`> * + * { margin-top }`).

Only the tour item itself stays flex (badge ↔ meta horizontal layout),
and that one still has overflow: hidden + flex: 1 1 0 + min-width: 0
on the meta column to truncate venue/place text with ellipsis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Psychotoxical <dev@psysonic.app>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 19:13:18 +02:00

74 lines
1.9 KiB
TypeScript

import { invoke } from '@tauri-apps/api/core';
/** Snake_case payload from the Rust `fetch_bandsintown_events` command. */
interface RawBandsintownEvent {
datetime: string;
venue_name: string;
venue_city: string;
venue_region: string;
venue_country: string;
url: string;
on_sale_datetime: string;
lineup: string[];
}
export interface BandsintownEvent {
datetime: string;
venueName: string;
venueCity: string;
venueRegion: string;
venueCountry: string;
url: string;
onSaleDatetime: string;
lineup: string[];
}
const cache = new Map<string, BandsintownEvent[]>();
const inflight = new Map<string, Promise<BandsintownEvent[]>>();
function cacheKey(name: string): string {
return name.trim().toLowerCase();
}
/**
* Fetch upcoming events for an artist. Results are cached in RAM for the session
* (no TTL — restart drops them). Concurrent calls for the same artist share one
* inflight promise. Failures resolve to an empty array — never throws.
*/
export async function fetchBandsintownEvents(artistName: string): Promise<BandsintownEvent[]> {
const key = cacheKey(artistName);
if (!key) return [];
const hit = cache.get(key);
if (hit) return hit;
const pending = inflight.get(key);
if (pending) return pending;
const promise = (async () => {
try {
const raw = await invoke<RawBandsintownEvent[]>('fetch_bandsintown_events', {
artistName,
});
const events: BandsintownEvent[] = (raw ?? []).map(r => ({
datetime: r.datetime,
venueName: r.venue_name,
venueCity: r.venue_city,
venueRegion: r.venue_region,
venueCountry: r.venue_country,
url: r.url,
onSaleDatetime: r.on_sale_datetime,
lineup: r.lineup ?? [],
}));
cache.set(key, events);
return events;
} catch {
cache.set(key, []);
return [];
} finally {
inflight.delete(key);
}
})();
inflight.set(key, promise);
return promise;
}