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:
Frank Stellmacher
2026-04-21 19:13:18 +02:00
committed by GitHub
parent 76ed6eca55
commit 06140a490b
17 changed files with 1026 additions and 3 deletions
+79
View File
@@ -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,
])
+73
View File
@@ -0,0 +1,73 @@
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;
}
+8
View File
@@ -122,6 +122,14 @@ export interface SubsonicSong {
trackPeak?: number;
albumPeak?: number;
};
/** OpenSubsonic: structured composer credit (string for back-compat). */
displayComposer?: string;
/** OpenSubsonic: structured contributors list — Navidrome ≥ 0.55. */
contributors?: Array<{
role: string;
subRole?: string;
artist: { id?: string; name: string };
}>;
}
export interface InternetRadioStation {
+311
View File
@@ -0,0 +1,311 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { getArtistInfo, getSong, type SubsonicArtistInfo, type SubsonicSong } from '../api/subsonic';
import { fetchBandsintownEvents, type BandsintownEvent } from '../api/bandsintown';
import CachedImage from './CachedImage';
const TOUR_LIMIT = 5;
const BIO_CLAMP_LINES = 4;
/**
* Cross-mount caches keyed by stable IDs so jumping between tracks of the same
* artist / album doesn't refire the network call. Cleared on app restart.
*/
const artistInfoCache = new Map<string, SubsonicArtistInfo | null>();
const songDetailCache = new Map<string, SubsonicSong | null>();
function isoToParts(iso: string): { month: string; day: string; weekday: string; time: string } | null {
if (!iso) return null;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return null;
const month = d.toLocaleString(undefined, { month: 'short' });
const day = String(d.getDate());
const weekday = d.toLocaleString(undefined, { weekday: 'short' });
const time = d.toLocaleString(undefined, { hour: '2-digit', minute: '2-digit' });
return { month, day, weekday, time };
}
interface ContributorRow {
role: string;
names: string[];
}
/**
* Build credits from OpenSubsonic `contributors[]` only. The legacy
* artist/albumArtist/composer fallback is intentionally dropped it
* just repeats what's already shown above the tab.
*/
function buildContributorRows(
song: SubsonicSong | null | undefined,
mainArtistName: string,
): ContributorRow[] {
if (!song?.contributors || song.contributors.length === 0) return [];
const mainLower = mainArtistName.trim().toLowerCase();
const rows = new Map<string, Set<string>>();
for (const c of song.contributors) {
const role = c.role?.trim();
const name = c.artist?.name?.trim();
if (!role || !name) continue;
const label = c.subRole ? `${role}${c.subRole}` : role;
let bucket = rows.get(label);
if (!bucket) { bucket = new Set(); rows.set(label, bucket); }
bucket.add(name);
}
// Drop a row that only restates the main artist under the "artist" role.
const out: ContributorRow[] = [];
for (const [role, names] of rows.entries()) {
const list = Array.from(names);
const isMainArtistOnly =
role.toLowerCase().startsWith('artist') &&
list.length === 1 &&
list[0].toLowerCase() === mainLower;
if (isMainArtistOnly) continue;
out.push({ role, names: list });
}
return out;
}
export default function NowPlayingInfo() {
const { t } = useTranslation();
const currentTrack = usePlayerStore(s => s.currentTrack);
const enableBandsintown = useAuthStore(s => s.enableBandsintown);
const setEnableBandsintown = useAuthStore(s => s.setEnableBandsintown);
const artistName = currentTrack?.artist || '';
const artistId = currentTrack?.artistId || '';
const songId = currentTrack?.id || '';
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(
artistId ? artistInfoCache.get(artistId) ?? null : null,
);
const [songDetail, setSongDetail] = useState<SubsonicSong | null>(
songId ? songDetailCache.get(songId) ?? null : null,
);
const [tourEvents, setTourEvents] = useState<BandsintownEvent[]>([]);
const [tourLoading, setTourLoading] = useState(false);
const [bioExpanded, setBioExpanded] = useState(false);
const [bioOverflows, setBioOverflows] = useState(false);
const [showAllTours, setShowAllTours] = useState(false);
const bioRef = useRef<HTMLParagraphElement | null>(null);
// Reset per-track UI state when the track changes
useEffect(() => { setBioExpanded(false); setShowAllTours(false); }, [artistId, songId]);
// Artist bio + image
useEffect(() => {
if (!artistId) { setArtistInfo(null); return; }
const cached = artistInfoCache.get(artistId);
if (cached !== undefined) { setArtistInfo(cached); return; }
let cancelled = false;
getArtistInfo(artistId)
.then(info => { if (!cancelled) { artistInfoCache.set(artistId, info ?? null); setArtistInfo(info ?? null); } })
.catch(() => { if (!cancelled) { artistInfoCache.set(artistId, null); setArtistInfo(null); } });
return () => { cancelled = true; };
}, [artistId]);
// Song detail (for OpenSubsonic contributors[])
useEffect(() => {
if (!songId) { setSongDetail(null); return; }
const cached = songDetailCache.get(songId);
if (cached !== undefined) { setSongDetail(cached); return; }
let cancelled = false;
getSong(songId)
.then(song => { if (!cancelled) { songDetailCache.set(songId, song ?? null); setSongDetail(song ?? null); } })
.catch(() => { if (!cancelled) { songDetailCache.set(songId, null); setSongDetail(null); } });
return () => { cancelled = true; };
}, [songId]);
// Bandsintown — only when opt-in toggle is on
useEffect(() => {
if (!enableBandsintown || !artistName) { setTourEvents([]); return; }
let cancelled = false;
setTourLoading(true);
fetchBandsintownEvents(artistName)
.then(events => { if (!cancelled) setTourEvents(events); })
.finally(() => { if (!cancelled) setTourLoading(false); });
return () => { cancelled = true; };
}, [enableBandsintown, artistName]);
// Detect whether the (clamped) bio actually overflows so we hide the toggle
// when it would do nothing.
const bio = artistInfo?.biography?.trim() || '';
const bioClean = bio.replace(/<a [^>]*>.*?<\/a>\.?/gi, '').trim();
useLayoutEffect(() => {
const el = bioRef.current;
if (!el) { setBioOverflows(false); return; }
setBioOverflows(el.scrollHeight - el.clientHeight > 1);
}, [bioClean]);
const contributorRows = useMemo(
() => buildContributorRows(songDetail, artistName),
[songDetail, artistName],
);
if (!currentTrack) {
return (
<div className="np-info-empty">
{t('nowPlayingInfo.empty', 'Play something to see info')}
</div>
);
}
const heroImage = artistInfo?.largeImageUrl || artistInfo?.mediumImageUrl || '';
const heroCacheKey = artistId ? `artistInfo:${artistId}:hero` : '';
const visibleTours = showAllTours ? tourEvents : tourEvents.slice(0, TOUR_LIMIT);
const hiddenTourCount = Math.max(0, tourEvents.length - visibleTours.length);
return (
<div className="np-info">
{/* Artist card */}
<section className="np-info-section np-info-artist">
{heroImage && heroCacheKey && (
<div className="np-info-artist-image-wrap">
<CachedImage
src={heroImage}
cacheKey={heroCacheKey}
alt={artistName}
className="np-info-artist-image"
/>
</div>
)}
<div className="np-info-artist-body">
<div className="np-info-section-title">{t('nowPlayingInfo.artist', 'Artist')}</div>
<div className="np-info-artist-name">{artistName || t('common.unknownArtist', 'Unknown artist')}</div>
{bioClean && (
<>
<p
ref={bioRef}
className={`np-info-artist-bio${bioExpanded ? '' : ' is-clamped'}`}
style={bioExpanded ? undefined : { WebkitLineClamp: BIO_CLAMP_LINES }}
>
{bioClean}
</p>
{(bioOverflows || bioExpanded) && (
<button
type="button"
className="np-info-link-btn"
onClick={() => setBioExpanded(v => !v)}
>
{bioExpanded
? t('nowPlayingInfo.bioReadLess', 'Show less')
: t('nowPlayingInfo.bioReadMore', 'Read more')}
</button>
)}
</>
)}
</div>
</section>
{/* Song info / contributors — only when OpenSubsonic provided real credits */}
{contributorRows.length > 0 && (
<section className="np-info-section">
<div className="np-info-section-title">{t('nowPlayingInfo.songInfo', 'Song info')}</div>
<ul className="np-info-credits">
{contributorRows.map(row => (
<li key={row.role} className="np-info-credit-row">
<span className="np-info-credit-names">{row.names.join(', ')}</span>
<span className="np-info-credit-role">{t(`nowPlayingInfo.role.${row.role}`, row.role)}</span>
</li>
))}
</ul>
</section>
)}
{/* Tour: prompt to opt-in when off, list when on */}
{!enableBandsintown ? (
<section className="np-info-section">
<div className="np-info-bandsintown-prompt">
<div className="np-info-bandsintown-prompt-title">
<span>{t('nowPlayingInfo.enableBandsintownPrompt', 'See upcoming tour dates?')}</span>
<span
className="np-info-bandsintown-prompt-info"
data-tooltip={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
data-tooltip-pos="bottom"
data-tooltip-wrap="true"
aria-label={t('nowPlayingInfo.enableBandsintownPrivacy', 'When enabled, the current artist\'s name is sent to the Bandsintown API to fetch tour dates. No personal account information leaves your device.')}
tabIndex={0}
>
<Info size={13} />
</span>
</div>
<div className="np-info-bandsintown-prompt-desc">
{t('nowPlayingInfo.enableBandsintownPromptDesc', 'Optional. Loads concerts for the current artist via Bandsintown.')}
</div>
<button
type="button"
className="np-info-bandsintown-prompt-btn"
onClick={() => setEnableBandsintown(true)}
>
{t('nowPlayingInfo.enableBandsintownAction', 'Enable')}
</button>
</div>
</section>
) : (
<section className="np-info-section">
<div className="np-info-section-title">{t('nowPlayingInfo.onTour', 'On tour')}</div>
{tourLoading && tourEvents.length === 0 && (
<div className="np-info-tour-empty">{t('nowPlayingInfo.tourLoading', 'Loading…')}</div>
)}
{!tourLoading && tourEvents.length === 0 && (
<div className="np-info-tour-empty">{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}</div>
)}
{visibleTours.length > 0 && (
<ul className="np-info-tour">
{visibleTours.map((ev, idx) => {
const parts = isoToParts(ev.datetime);
const place = [ev.venueCity, ev.venueRegion, ev.venueCountry]
.filter(Boolean).join(', ');
return (
<li
key={`${ev.datetime}-${ev.venueName}-${idx}`}
className="np-info-tour-item"
onClick={() => ev.url && shellOpen(ev.url).catch(() => {})}
role={ev.url ? 'button' : undefined}
tabIndex={ev.url ? 0 : undefined}
>
{parts && (
<div className="np-info-tour-date">
<div className="np-info-tour-date-month">{parts.month}</div>
<div className="np-info-tour-date-day">{parts.day}</div>
</div>
)}
<div className="np-info-tour-meta">
<div className="np-info-tour-venue">{ev.venueName || place}</div>
<div className="np-info-tour-place">
{parts && (
<span className="np-info-tour-when">{parts.weekday}, {parts.time}</span>
)}
{parts && place && <span className="np-info-tour-sep"> </span>}
<span>{place}</span>
</div>
</div>
</li>
);
})}
</ul>
)}
{(hiddenTourCount > 0 || (showAllTours && tourEvents.length > TOUR_LIMIT)) && (
<button
type="button"
className="np-info-tour-more"
onClick={() => setShowAllTours(v => !v)}
>
{showAllTours
? t('nowPlayingInfo.showLessTours', 'Show less')
: t('nowPlayingInfo.showMoreTours', { defaultValue: 'Show {{count}} more', count: hiddenTourCount })}
</button>
)}
<div className="np-info-tour-credit">
{t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')}
</div>
</section>
)}
</div>
);
}
+13 -2
View File
@@ -1,6 +1,6 @@
import React, { useState, useRef, useMemo } from 'react';
import { Track, usePlayerStore, songToTrack } from '../store/playerStore';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown } from 'lucide-react';
import { Play, Music, Star, X, Trash2, Save, FolderOpen, Shuffle, Infinity, Waves, MicVocal, ListMusic, Check, ListPlus, ArrowUpToLine, Radio, HardDrive, ChevronDown, Info } from 'lucide-react';
import { buildCoverArtUrl, coverArtCacheKey, getAlbum, getPlaylists, getPlaylist, updatePlaylist, deletePlaylist, SubsonicPlaylist } from '../api/subsonic';
import { usePlaylistStore } from '../store/playlistStore';
import { useCachedUrl } from './CachedImage';
@@ -12,6 +12,7 @@ import { useThemeStore } from '../store/themeStore';
import { useLyricsStore } from '../store/lyricsStore';
import { useDragDrop } from '../contexts/DragDropContext';
import LyricsPane from './LyricsPane';
import NowPlayingInfo from './NowPlayingInfo';
import { TFunction } from 'i18next';
function formatTime(seconds: number): string {
@@ -696,8 +697,10 @@ export default function QueuePanel() {
})
)}
</div>
</>) : (
</>) : activeTab === 'lyrics' ? (
<LyricsPane currentTrack={currentTrack} />
) : (
<NowPlayingInfo />
)}
<div className="queue-tab-bar">
@@ -717,6 +720,14 @@ export default function QueuePanel() {
<MicVocal size={14} />
{t('player.lyrics')}
</button>
<button
className={`queue-tab-btn${activeTab === 'info' ? ' active' : ''}`}
onClick={() => setTab('info')}
aria-label={t('nowPlayingInfo.tab')}
>
<Info size={14} />
{t('nowPlayingInfo.tab')}
</button>
</div>
{saveModalOpen && (
+26
View File
@@ -606,6 +606,8 @@ export const deTranslation = {
discordTemplateLargeText: 'Album-Tooltip (largeText)',
nowPlayingEnabled: 'Im Livefenster anzeigen',
nowPlayingEnabledDesc: 'Überträgt den aktuell gespielten Titel an die Livehörer-Ansicht des Servers. Deaktivieren, um keine Wiedergabedaten zu senden.',
enableBandsintown: 'Bandsintown-Tourdaten',
enableBandsintownDesc: 'Zeigt anstehende Konzerte des aktuellen Künstlers im Info-Tab. Daten werden über die öffentliche Bandsintown-API abgerufen.',
lyricsServerFirst: 'Server-Lyrics bevorzugen',
lyricsServerFirstDesc: 'Server-seitige Lyrics (eingebettete Tags, Sidecar-Dateien) vor LRCLIB abfragen. Deaktivieren, um LRCLIB zuerst zu verwenden.',
enableNeteaselyrics: 'Netease Cloud Music Lyrics',
@@ -1049,6 +1051,30 @@ export const deTranslation = {
showDuration: 'Dauer anzeigen',
showRemainingTime: 'Verbleibende Zeit anzeigen',
},
nowPlayingInfo: {
tab: 'Info',
empty: 'Spiele etwas, um Infos zu sehen',
artist: 'Künstler*in',
songInfo: 'Songinfos',
onTour: 'Auf Tour',
noTourEvents: 'Keine bevorstehenden Konzerte',
tourLoading: 'Wird geladen…',
poweredByBandsintown: 'Tourdaten via Bandsintown',
bioReadMore: 'Mehr anzeigen',
bioReadLess: 'Weniger anzeigen',
showMoreTours_one: '{{count}} weiteres anzeigen',
showMoreTours_other: '{{count}} weitere anzeigen',
showLessTours: 'Weniger anzeigen',
enableBandsintownPrompt: 'Anstehende Konzerte anzeigen?',
enableBandsintownPromptDesc: 'Optional. Lädt Tourdaten der aktuellen Künstler*in über die öffentliche Bandsintown-API.',
enableBandsintownPrivacy: 'Beim Aktivieren wird der Name der aktuell gespielten Künstler*in an die Bandsintown-API übertragen, um Tourdaten abzurufen. Es werden keine Konto- oder persönlichen Daten gesendet.',
enableBandsintownAction: 'Aktivieren',
role: {
artist: 'Hauptkünstler*in',
albumArtist: 'Album-Künstler*in',
composer: 'Komponist*in',
},
},
songInfo: {
title: 'Song-Infos',
songTitle: 'Titel',
+26
View File
@@ -608,6 +608,8 @@ export const enTranslation = {
discordTemplateLargeText: 'Album tooltip (largeText)',
nowPlayingEnabled: 'Show in Now Playing',
nowPlayingEnabledDesc: 'Broadcast your currently playing track to the server\'s live listener view. Disable to stop sending playback data.',
enableBandsintown: 'Bandsintown tour dates',
enableBandsintownDesc: 'Show upcoming concerts for the current artist in the Info tab. Data is fetched from the public Bandsintown API.',
lyricsServerFirst: 'Prefer server lyrics',
lyricsServerFirstDesc: 'Check server-provided lyrics (embedded tags, sidecar files) before querying LRCLIB. Disable to use LRCLIB first.',
enableNeteaselyrics: 'Netease Cloud Music lyrics',
@@ -1051,6 +1053,30 @@ export const enTranslation = {
showDuration: 'Show duration',
showRemainingTime: 'Show remaining time',
},
nowPlayingInfo: {
tab: 'Info',
empty: 'Play something to see info',
artist: 'Artist',
songInfo: 'Song info',
onTour: 'On tour',
noTourEvents: 'No upcoming shows',
tourLoading: 'Loading…',
poweredByBandsintown: 'Tour data via Bandsintown',
bioReadMore: 'Read more',
bioReadLess: 'Show less',
showMoreTours_one: 'Show {{count}} more',
showMoreTours_other: 'Show {{count}} more',
showLessTours: 'Show less',
enableBandsintownPrompt: 'See upcoming tour dates?',
enableBandsintownPromptDesc: 'Optional. Loads concerts for the current artist via the public Bandsintown API.',
enableBandsintownPrivacy: 'When enabled, the name of the currently playing artist is sent to the Bandsintown API to fetch tour dates. No account or personal data leaves your device.',
enableBandsintownAction: 'Enable',
role: {
artist: 'Artist',
albumArtist: 'Album artist',
composer: 'Composer',
},
},
songInfo: {
title: 'Song Info',
songTitle: 'Title',
+26
View File
@@ -599,6 +599,8 @@ export const esTranslation = {
discordTemplateLargeText: 'Tooltip del álbum (largeText)',
nowPlayingEnabled: 'Mostrar en Reproduciendo Ahora',
nowPlayingEnabledDesc: 'Transmite tu pista actual a la vista de oyentes en vivo del servidor. Desactiva para dejar de enviar datos de reproducción.',
enableBandsintown: 'Fechas de gira de Bandsintown',
enableBandsintownDesc: 'Muestra próximos conciertos del artista actual en la pestaña Info. Los datos se obtienen de la API pública de Bandsintown.',
lyricsServerFirst: 'Preferir letras del servidor',
lyricsServerFirstDesc: 'Verifica primero letras provistas por el servidor (etiquetas embebidas, archivos sidecar) antes de consultar LRCLIB. Desactiva para usar LRCLIB primero.',
enableNeteaselyrics: 'Letras de Netease Cloud Music',
@@ -1042,6 +1044,30 @@ export const esTranslation = {
showDuration: 'Mostrar duración',
showRemainingTime: 'Mostrar tiempo restante',
},
nowPlayingInfo: {
tab: 'Info',
empty: 'Reproduce algo para ver información',
artist: 'Artista',
songInfo: 'Info de la canción',
onTour: 'En gira',
noTourEvents: 'No hay próximos conciertos',
tourLoading: 'Cargando…',
poweredByBandsintown: 'Datos de gira vía Bandsintown',
bioReadMore: 'Leer más',
bioReadLess: 'Ver menos',
showMoreTours_one: 'Mostrar {{count}} más',
showMoreTours_other: 'Mostrar {{count}} más',
showLessTours: 'Ver menos',
enableBandsintownPrompt: '¿Ver próximas fechas de gira?',
enableBandsintownPromptDesc: 'Opcional. Carga conciertos del artista actual vía la API pública de Bandsintown.',
enableBandsintownPrivacy: 'Al activar, el nombre del artista actual se envía a la API de Bandsintown para obtener fechas de gira. No se envían datos de cuenta ni personales.',
enableBandsintownAction: 'Activar',
role: {
artist: 'Artista',
albumArtist: 'Artista del álbum',
composer: 'Compositor',
},
},
songInfo: {
title: 'Información de Canción',
songTitle: 'Título',
+26
View File
@@ -594,6 +594,8 @@ export const frTranslation = {
discordTemplateLargeText: 'Info-bulle album (largeText)',
nowPlayingEnabled: 'Afficher dans la fenêtre live',
nowPlayingEnabledDesc: 'Diffuse le titre en cours de lecture vers la vue des auditeurs en direct du serveur. Désactiver pour ne pas envoyer de données de lecture.',
enableBandsintown: 'Dates de tournée Bandsintown',
enableBandsintownDesc: 'Affiche les concerts à venir de l\'artiste actuel dans l\'onglet Info. Les données proviennent de l\'API publique Bandsintown.',
lyricsServerFirst: 'Préférer les paroles du serveur',
lyricsServerFirstDesc: 'Consulter d\'abord les paroles fournies par le serveur (tags intégrés, fichiers sidecar) avant LRCLIB. Désactiver pour utiliser LRCLIB en priorité.',
enableNeteaselyrics: 'Paroles Netease Cloud Music',
@@ -1037,6 +1039,30 @@ export const frTranslation = {
showDuration: 'Afficher la durée',
showRemainingTime: 'Afficher le temps restant',
},
nowPlayingInfo: {
tab: 'Info',
empty: 'Lancez une lecture pour voir les infos',
artist: 'Artiste',
songInfo: 'Infos du morceau',
onTour: 'En tournée',
noTourEvents: 'Aucun concert à venir',
tourLoading: 'Chargement…',
poweredByBandsintown: 'Données de tournée via Bandsintown',
bioReadMore: 'En lire plus',
bioReadLess: 'Réduire',
showMoreTours_one: 'Afficher {{count}} de plus',
showMoreTours_other: 'Afficher {{count}} de plus',
showLessTours: 'Réduire',
enableBandsintownPrompt: 'Afficher les prochaines dates de tournée ?',
enableBandsintownPromptDesc: 'Optionnel. Charge les concerts de l\'artiste via l\'API publique Bandsintown.',
enableBandsintownPrivacy: 'Lors de l\'activation, le nom de l\'artiste en cours de lecture est envoyé à l\'API Bandsintown pour récupérer les dates de tournée. Aucun compte ni données personnelles ne quittent votre appareil.',
enableBandsintownAction: 'Activer',
role: {
artist: 'Artiste',
albumArtist: 'Artiste de l\'album',
composer: 'Compositeur',
},
},
songInfo: {
title: 'Infos du morceau',
songTitle: 'Titre',
+26
View File
@@ -593,6 +593,8 @@ export const nbTranslation = {
discordTemplateLargeText: 'Album-verktøytips (largeText)',
nowPlayingEnabled: 'Vis i "Nå spiller"',
nowPlayingEnabledDesc: 'Send sporet som spilles av til tjenerens live-lyttervisning. Deaktiver for å stoppe sending av avspillingsdata.',
enableBandsintown: 'Bandsintown-turnédatoer',
enableBandsintownDesc: 'Vis kommende konserter for gjeldende artist i Info-fanen. Data hentes fra det offentlige Bandsintown-API-et.',
lyricsServerFirst: 'Foretrekk server-sangtekst',
lyricsServerFirstDesc: 'Sjekk tjenerlevererte sangtekster (innebygde tagger, sidecar-filer) før LRCLIB. Deaktiver for å bruke LRCLIB først.',
enableNeteaselyrics: 'Netease Cloud Music sangtekster',
@@ -1036,6 +1038,30 @@ export const nbTranslation = {
showDuration: 'Vis varighet',
showRemainingTime: 'Vis gjenværende tid',
},
nowPlayingInfo: {
tab: 'Info',
empty: 'Spill noe for å se info',
artist: 'Artist',
songInfo: 'Sporinfo',
onTour: 'På turné',
noTourEvents: 'Ingen kommende konserter',
tourLoading: 'Laster…',
poweredByBandsintown: 'Turnédata via Bandsintown',
bioReadMore: 'Vis mer',
bioReadLess: 'Vis mindre',
showMoreTours_one: 'Vis {{count}} til',
showMoreTours_other: 'Vis {{count}} til',
showLessTours: 'Vis mindre',
enableBandsintownPrompt: 'Vis kommende turnédatoer?',
enableBandsintownPromptDesc: 'Valgfritt. Henter konserter for gjeldende artist via det offentlige Bandsintown-API-et.',
enableBandsintownPrivacy: 'Ved aktivering sendes navnet på artisten som spilles av til Bandsintown-API-et for å hente turnédatoer. Ingen konto- eller personopplysninger forlater enheten din.',
enableBandsintownAction: 'Aktiver',
role: {
artist: 'Artist',
albumArtist: 'Albumartist',
composer: 'Komponist',
},
},
songInfo: {
title: 'Sanginfo',
songTitle: 'Tittel',
+26
View File
@@ -593,6 +593,8 @@ export const nlTranslation = {
discordTemplateLargeText: 'Album-tooltip (largeText)',
nowPlayingEnabled: 'Weergeven in live-venster',
nowPlayingEnabledDesc: 'Stuurt het huidige nummer naar de live-luisteraarsweergave van de server. Uitschakelen om geen afspeelgegevens te verzenden.',
enableBandsintown: 'Bandsintown-tourdata',
enableBandsintownDesc: 'Toon aankomende concerten van de huidige artiest in het Info-tabblad. Gegevens komen van de openbare Bandsintown-API.',
lyricsServerFirst: 'Server-songtekst voorrang geven',
lyricsServerFirstDesc: 'Controleer eerst door de server geleverde songteksten (ingebedde tags, sidecar-bestanden) vóór LRCLIB. Uitschakelen om LRCLIB eerst te gebruiken.',
enableNeteaselyrics: 'Netease Cloud Music songteksten',
@@ -1036,6 +1038,30 @@ export const nlTranslation = {
showDuration: 'Toon duur',
showRemainingTime: 'Toon resterende tijd',
},
nowPlayingInfo: {
tab: 'Info',
empty: 'Speel iets af om informatie te zien',
artist: 'Artiest',
songInfo: 'Nummer-info',
onTour: 'Op tour',
noTourEvents: 'Geen aankomende concerten',
tourLoading: 'Laden…',
poweredByBandsintown: 'Tourdata via Bandsintown',
bioReadMore: 'Meer tonen',
bioReadLess: 'Minder tonen',
showMoreTours_one: '{{count}} meer tonen',
showMoreTours_other: '{{count}} meer tonen',
showLessTours: 'Minder tonen',
enableBandsintownPrompt: 'Aankomende tourdata tonen?',
enableBandsintownPromptDesc: 'Optioneel. Laadt concerten van de huidige artiest via de openbare Bandsintown-API.',
enableBandsintownPrivacy: 'Bij inschakelen wordt de naam van de huidige artiest naar de Bandsintown-API gestuurd om tourdata op te halen. Er worden geen account- of persoonlijke gegevens verzonden.',
enableBandsintownAction: 'Inschakelen',
role: {
artist: 'Artiest',
albumArtist: 'Albumartiest',
composer: 'Componist',
},
},
songInfo: {
title: 'Nummerinfo',
songTitle: 'Titel',
+28
View File
@@ -619,6 +619,8 @@ export const ruTranslation = {
nowPlayingEnabled: 'Показывать в «Сейчас играет»',
nowPlayingEnabledDesc:
'Отправлять на сервер, что вы сейчас слушаете. Отключите, чтобы не делиться этим.',
enableBandsintown: 'Даты туров Bandsintown',
enableBandsintownDesc: 'Показывает предстоящие концерты текущего исполнителя на вкладке «Инфо». Данные берутся из публичного API Bandsintown.',
lyricsServerFirst: 'Предпочитать тексты с сервера',
lyricsServerFirstDesc: 'Проверять тексты песен, предоставленные сервером (встроенные теги, sidecar-файлы) перед запросом LRCLIB. Отключите, чтобы сначала использовать LRCLIB.',
enableNeteaselyrics: 'Тексты из Netease Cloud Music',
@@ -1097,6 +1099,32 @@ export const ruTranslation = {
showDuration: 'Показать длительность',
showRemainingTime: 'Показать оставшееся время',
},
nowPlayingInfo: {
tab: 'Инфо',
empty: 'Включите воспроизведение, чтобы увидеть информацию',
artist: 'Исполнитель',
songInfo: 'О треке',
onTour: 'В туре',
noTourEvents: 'Нет предстоящих концертов',
tourLoading: 'Загрузка…',
poweredByBandsintown: 'Данные о туре через Bandsintown',
bioReadMore: 'Читать дальше',
bioReadLess: 'Свернуть',
showMoreTours_one: 'Показать ещё {{count}}',
showMoreTours_few: 'Показать ещё {{count}}',
showMoreTours_many: 'Показать ещё {{count}}',
showMoreTours_other: 'Показать ещё {{count}}',
showLessTours: 'Свернуть',
enableBandsintownPrompt: 'Показать предстоящие концерты?',
enableBandsintownPromptDesc: 'Опционально. Загружает концерты текущего исполнителя через публичный API Bandsintown.',
enableBandsintownPrivacy: 'При включении имя текущего исполнителя отправляется в API Bandsintown для получения дат концертов. Аккаунт и личные данные не передаются.',
enableBandsintownAction: 'Включить',
role: {
artist: 'Исполнитель',
albumArtist: 'Исполнитель альбома',
composer: 'Композитор',
},
},
songInfo: {
title: 'О треке',
songTitle: 'Название',
+26
View File
@@ -589,6 +589,8 @@ export const zhTranslation = {
discordTemplateLargeText: '专辑提示 (largeText)',
nowPlayingEnabled: '在实时窗口中显示',
nowPlayingEnabledDesc: '将当前播放的曲目广播到服务器的实时听众视图。禁用以停止发送播放数据。',
enableBandsintown: 'Bandsintown 巡演日期',
enableBandsintownDesc: '在信息标签页中显示当前艺术家的即将到来的演出。数据通过公开的 Bandsintown API 获取。',
lyricsServerFirst: '优先使用服务器歌词',
lyricsServerFirstDesc: '先查询服务器提供的歌词(内嵌标签、sidecar 文件),再查询 LRCLIB。禁用则优先使用 LRCLIB。',
enableNeteaselyrics: '网易云音乐歌词',
@@ -1032,6 +1034,30 @@ export const zhTranslation = {
showDuration: '显示时长',
showRemainingTime: '显示剩余时间',
},
nowPlayingInfo: {
tab: '信息',
empty: '播放歌曲以查看信息',
artist: '艺术家',
songInfo: '歌曲信息',
onTour: '巡演',
noTourEvents: '没有即将到来的演出',
tourLoading: '加载中…',
poweredByBandsintown: '巡演数据来自 Bandsintown',
bioReadMore: '展开',
bioReadLess: '收起',
showMoreTours_one: '显示更多 {{count}} 个',
showMoreTours_other: '显示更多 {{count}} 个',
showLessTours: '收起',
enableBandsintownPrompt: '查看即将到来的巡演日期?',
enableBandsintownPromptDesc: '可选。通过公开的 Bandsintown API 加载当前艺术家的演出。',
enableBandsintownPrivacy: '启用后,当前播放的艺术家名称将发送到 Bandsintown API 以获取巡演日期。不会发送任何账户或个人数据。',
enableBandsintownAction: '启用',
role: {
artist: '艺术家',
albumArtist: '专辑艺术家',
composer: '作曲',
},
},
songInfo: {
title: '歌曲信息',
songTitle: '标题',
+28
View File
@@ -1817,6 +1817,34 @@ export default function Settings() {
<span className="toggle-track" />
</label>
</div>
<div className="settings-section-divider" />
<div className="settings-toggle-row">
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 6 }}>
<span>{t('settings.enableBandsintown')}</span>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
color: 'var(--text-muted)',
cursor: 'help',
}}
data-tooltip={t('nowPlayingInfo.enableBandsintownPrivacy')}
data-tooltip-pos="bottom"
data-tooltip-wrap="true"
aria-label={t('nowPlayingInfo.enableBandsintownPrivacy')}
tabIndex={0}
>
<Info size={13} />
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)' }}>{t('settings.enableBandsintownDesc')}</div>
</div>
<label className="toggle-switch" aria-label={t('settings.enableBandsintown')}>
<input type="checkbox" checked={auth.enableBandsintown} onChange={e => auth.setEnableBandsintown(e.target.checked)} />
<span className="toggle-track" />
</label>
</div>
</div>
</section>
+5
View File
@@ -63,6 +63,8 @@ interface AuthState {
minimizeToTray: boolean;
discordRichPresence: boolean;
enableAppleMusicCoversDiscord: boolean;
/** Opt-in: fetch upcoming tour dates from Bandsintown for the Now-Playing info panel. */
enableBandsintown: boolean;
discordTemplateDetails: string;
discordTemplateState: string;
discordTemplateLargeText: string;
@@ -214,6 +216,7 @@ interface AuthState {
setMinimizeToTray: (v: boolean) => void;
setDiscordRichPresence: (v: boolean) => void;
setEnableAppleMusicCoversDiscord: (v: boolean) => void;
setEnableBandsintown: (v: boolean) => void;
setDiscordTemplateDetails: (v: string) => void;
setDiscordTemplateState: (v: string) => void;
setDiscordTemplateLargeText: (v: string) => void;
@@ -322,6 +325,7 @@ export const useAuthStore = create<AuthState>()(
minimizeToTray: false,
discordRichPresence: false,
enableAppleMusicCoversDiscord: false,
enableBandsintown: false,
discordTemplateDetails: '{artist} - {title}',
discordTemplateState: '{album}',
discordTemplateLargeText: '{album}',
@@ -454,6 +458,7 @@ export const useAuthStore = create<AuthState>()(
setMinimizeToTray: (v) => set({ minimizeToTray: v }),
setDiscordRichPresence: (v) => set({ discordRichPresence: v }),
setEnableAppleMusicCoversDiscord: (v) => set({ enableAppleMusicCoversDiscord: v }),
setEnableBandsintown: (v) => set({ enableBandsintown: v }),
setDiscordTemplateDetails: (v) => set({ discordTemplateDetails: v }),
setDiscordTemplateState: (v) => set({ discordTemplateState: v }),
setDiscordTemplateLargeText: (v) => set({ discordTemplateLargeText: v }),
+3 -1
View File
@@ -1,12 +1,13 @@
import { create } from 'zustand';
type SidebarTab = 'queue' | 'lyrics';
type SidebarTab = 'queue' | 'lyrics' | 'info';
interface LyricsState {
activeTab: SidebarTab;
setTab: (tab: SidebarTab) => void;
showLyrics: () => void;
showQueue: () => void;
showInfo: () => void;
}
export const useLyricsStore = create<LyricsState>()((set) => ({
@@ -14,4 +15,5 @@ export const useLyricsStore = create<LyricsState>()((set) => ({
setTab: (tab) => set({ activeTab: tab }),
showLyrics: () => set({ activeTab: 'lyrics' }),
showQueue: () => set({ activeTab: 'queue' }),
showInfo: () => set({ activeTab: 'info' }),
}));
+296
View File
@@ -9574,3 +9574,299 @@ html[data-app-hidden="true"] *::before,
html[data-app-hidden="true"] *::after {
animation-play-state: paused !important;
}
/* ── Now Playing Info panel ──────────────────────────────────────────────── */
.np-info {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 6px 16px 20px;
display: block;
color: var(--text-primary);
text-align: left;
min-width: 0;
}
.np-info-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px 16px;
color: var(--text-muted);
font-size: 13px;
text-align: center;
}
.np-info-section {
display: block;
padding: 18px 0;
border-bottom: 1px solid color-mix(in srgb, var(--border-subtle, var(--ctp-surface0)) 70%, transparent);
}
.np-info-section > * + * { margin-top: 12px; }
.np-info-section:last-child {
border-bottom: none;
padding-bottom: 4px;
}
.np-info-section:first-child {
padding-top: 10px;
}
.np-info-section-title {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--text-muted);
text-align: left;
}
/* Artist card */
.np-info-artist-image-wrap {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 12px;
overflow: hidden;
background: var(--bg-elevated);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
.np-info-artist-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.np-info-artist-body {
display: block;
}
.np-info-artist-body > * + * { margin-top: 8px; }
.np-info-artist-name {
font-size: 22px;
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.01em;
text-align: left;
}
.np-info-artist-bio {
margin: 0;
font-size: 13px;
line-height: 1.55;
color: var(--text-secondary);
text-align: left;
}
.np-info-artist-bio.is-clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
overflow: hidden;
}
.np-info-link-btn {
align-self: flex-start;
background: none;
border: none;
padding: 4px 0;
margin: -2px 0 0;
font-size: 12px;
font-weight: 600;
color: var(--accent);
cursor: pointer;
text-align: left;
}
.np-info-link-btn:hover { text-decoration: underline; }
/* Credits / contributors — stacked: name prominent, role muted underneath */
.np-info-credits {
list-style: none;
margin: 0;
padding: 0;
display: block;
}
.np-info-credit-row {
display: block;
padding: 0;
text-align: left;
}
.np-info-credit-row + .np-info-credit-row { margin-top: 10px; }
.np-info-credit-names {
display: block;
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
text-align: left;
line-height: 1.3;
}
.np-info-credit-role {
display: block;
font-size: 11.5px;
color: var(--text-muted);
text-transform: capitalize;
text-align: left;
margin-top: 1px;
}
/* Tour */
.np-info-tour {
list-style: none;
margin: 0;
padding: 0;
display: block;
}
.np-info-tour > .np-info-tour-item + .np-info-tour-item { margin-top: 6px; }
.np-info-tour-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--bg-elevated);
border-radius: 10px;
cursor: pointer;
transition: background 0.15s;
border: none;
text-align: left;
width: 100%;
min-width: 0; /* allow children to shrink past their min-content */
box-sizing: border-box;
overflow: hidden;
}
.np-info-tour-item:hover {
background: color-mix(in srgb, var(--accent) 10%, var(--bg-elevated));
}
.np-info-tour-date {
flex-shrink: 0;
width: 48px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6px 0 5px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 16%, transparent);
color: var(--accent);
line-height: 1;
}
.np-info-tour-date-month {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.np-info-tour-date-day {
font-size: 19px;
font-weight: 700;
margin-top: 3px;
font-variant-numeric: tabular-nums;
}
.np-info-tour-meta {
/* `flex: 1 1 0` + `min-width: 0` is the canonical fix for the flex
* truncation bug without `flex-basis: 0` the meta column inherits
* its intrinsic width from the longest venue/place text, which then
* overflows the parent ul on WebKit. */
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
overflow: hidden;
}
.np-info-tour-venue {
font-size: 13.5px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.np-info-tour-place {
font-size: 11.5px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: left;
}
.np-info-tour-when { color: var(--text-secondary); }
.np-info-tour-sep { opacity: 0.55; margin: 0 2px; }
.np-info-tour-empty {
font-size: 13px;
color: var(--text-muted);
padding: 8px 0;
text-align: left;
}
.np-info-tour-credit {
font-size: 10.5px;
color: var(--text-muted);
margin-top: 10px;
text-align: right;
opacity: 0.55;
letter-spacing: 0.02em;
}
.np-info-tour-more {
align-self: center;
margin-top: 4px;
padding: 6px 14px;
background: none;
border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent);
border-radius: 999px;
color: var(--accent);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.np-info-tour-more:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
border-color: var(--accent);
}
/* Bandsintown opt-in prompt — shown in place of tour list when toggle is off */
.np-info-bandsintown-prompt {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border-radius: 12px;
background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated));
border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent);
}
.np-info-bandsintown-prompt-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13.5px;
font-weight: 600;
color: var(--text-primary);
}
.np-info-bandsintown-prompt-info {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
cursor: help;
border-radius: 50%;
padding: 2px;
transition: color 0.15s, background 0.15s;
}
.np-info-bandsintown-prompt-info:hover,
.np-info-bandsintown-prompt-info:focus-visible {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 12%, transparent);
outline: none;
}
.np-info-bandsintown-prompt-desc {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.45;
}
.np-info-bandsintown-prompt-btn {
align-self: flex-start;
margin-top: 6px;
padding: 6px 16px;
background: var(--accent);
border: none;
border-radius: 999px;
color: var(--ctp-base, #1e1e2e);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
transition: filter 0.12s;
}
.np-info-bandsintown-prompt-btn:hover { filter: brightness(1.08); }