mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +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,
|
||||
])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'Название',
|
||||
|
||||
@@ -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: '标题',
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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' }),
|
||||
}));
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user