mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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>
This commit is contained in:
committed by
GitHub
parent
76ed6eca55
commit
06140a490b
@@ -3155,6 +3155,84 @@ fn resize_mini_player(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Bandsintown ──────────────────────────────────────────────────────────────
|
||||
// Public REST API: https://rest.bandsintown.com/artists/{name}/events?app_id=…
|
||||
// Bandsintown whitelists app IDs — arbitrary strings now return 403 Forbidden.
|
||||
// `js_app_id` is the ID their own embeddable widget uses and is broadly accepted.
|
||||
const BANDSINTOWN_APP_ID: &str = "js_app_id";
|
||||
|
||||
#[derive(serde::Serialize, Default)]
|
||||
struct BandsintownEvent {
|
||||
datetime: String, // ISO 8601 (e.g. "2026-04-23T20:30:00")
|
||||
venue_name: String,
|
||||
venue_city: String,
|
||||
venue_region: String,
|
||||
venue_country: String,
|
||||
url: String,
|
||||
on_sale_datetime: String,
|
||||
lineup: Vec<String>,
|
||||
}
|
||||
|
||||
/// Fetch upcoming Bandsintown events for an artist by name.
|
||||
/// Returns an empty list on any failure (404, network, parse) — the UI
|
||||
/// just hides the section in that case.
|
||||
#[tauri::command]
|
||||
async fn fetch_bandsintown_events(artist_name: String) -> Result<Vec<BandsintownEvent>, String> {
|
||||
let trimmed = artist_name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
// Bandsintown expects the artist name URL-encoded; their API treats `/` as a
|
||||
// path separator (so e.g. AC/DC must be encoded as AC%252FDC).
|
||||
let encoded: String = url::form_urlencoded::byte_serialize(trimmed.as_bytes()).collect();
|
||||
let url = format!(
|
||||
"https://rest.bandsintown.com/artists/{}/events?app_id={}",
|
||||
encoded, BANDSINTOWN_APP_ID
|
||||
);
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
let raw: serde_json::Value = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Ok(vec![]),
|
||||
};
|
||||
let arr = match raw.as_array() {
|
||||
Some(a) => a,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
let mut out: Vec<BandsintownEvent> = Vec::with_capacity(arr.len().min(20));
|
||||
for item in arr.iter().take(20) {
|
||||
let venue = item.get("venue").cloned().unwrap_or(serde_json::Value::Null);
|
||||
let lineup = item
|
||||
.get("lineup")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|s| s.as_str().map(String::from)).collect())
|
||||
.unwrap_or_default();
|
||||
out.push(BandsintownEvent {
|
||||
datetime: item.get("datetime").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
venue_name: venue.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
venue_city: venue.get("city").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
venue_region: venue.get("region").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
venue_country: venue.get("country").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
url: item.get("url").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
on_sale_datetime: item.get("on_sale_datetime").and_then(|v| v.as_str()).unwrap_or("").to_string(),
|
||||
lineup,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
// Linux: second `psysonic --player …` forwards over D-Bus before heavy startup.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -3483,6 +3561,7 @@ pub fn run() {
|
||||
open_folder,
|
||||
get_embedded_lyrics,
|
||||
fetch_netease_lyrics,
|
||||
fetch_bandsintown_events,
|
||||
#[cfg(target_os = "windows")]
|
||||
taskbar_win::update_taskbar_icon,
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user