diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8bbbc8bb..edf2f620 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, +} + +/// 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, 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 = 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, ]) diff --git a/src/api/bandsintown.ts b/src/api/bandsintown.ts new file mode 100644 index 00000000..d06c09c4 --- /dev/null +++ b/src/api/bandsintown.ts @@ -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(); +const inflight = new Map>(); + +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 { + 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('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; +} diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 7b44615e..78735743 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -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 { diff --git a/src/components/NowPlayingInfo.tsx b/src/components/NowPlayingInfo.tsx new file mode 100644 index 00000000..79709625 --- /dev/null +++ b/src/components/NowPlayingInfo.tsx @@ -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(); +const songDetailCache = new Map(); + +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>(); + 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( + artistId ? artistInfoCache.get(artistId) ?? null : null, + ); + const [songDetail, setSongDetail] = useState( + songId ? songDetailCache.get(songId) ?? null : null, + ); + const [tourEvents, setTourEvents] = useState([]); + const [tourLoading, setTourLoading] = useState(false); + const [bioExpanded, setBioExpanded] = useState(false); + const [bioOverflows, setBioOverflows] = useState(false); + const [showAllTours, setShowAllTours] = useState(false); + + const bioRef = useRef(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>\.?/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 ( +
+ {t('nowPlayingInfo.empty', 'Play something to see info')} +
+ ); + } + + 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 ( +
+ {/* Artist card */} +
+ {heroImage && heroCacheKey && ( +
+ +
+ )} +
+
{t('nowPlayingInfo.artist', 'Artist')}
+
{artistName || t('common.unknownArtist', 'Unknown artist')}
+ {bioClean && ( + <> +

+ {bioClean} +

+ {(bioOverflows || bioExpanded) && ( + + )} + + )} +
+
+ + {/* Song info / contributors — only when OpenSubsonic provided real credits */} + {contributorRows.length > 0 && ( +
+
{t('nowPlayingInfo.songInfo', 'Song info')}
+
    + {contributorRows.map(row => ( +
  • + {row.names.join(', ')} + {t(`nowPlayingInfo.role.${row.role}`, row.role)} +
  • + ))} +
+
+ )} + + {/* Tour: prompt to opt-in when off, list when on */} + {!enableBandsintown ? ( +
+
+
+ {t('nowPlayingInfo.enableBandsintownPrompt', 'See upcoming tour dates?')} + + + +
+
+ {t('nowPlayingInfo.enableBandsintownPromptDesc', 'Optional. Loads concerts for the current artist via Bandsintown.')} +
+ +
+
+ ) : ( +
+
{t('nowPlayingInfo.onTour', 'On tour')}
+ {tourLoading && tourEvents.length === 0 && ( +
{t('nowPlayingInfo.tourLoading', 'Loading…')}
+ )} + {!tourLoading && tourEvents.length === 0 && ( +
{t('nowPlayingInfo.noTourEvents', 'No upcoming shows')}
+ )} + {visibleTours.length > 0 && ( +
    + {visibleTours.map((ev, idx) => { + const parts = isoToParts(ev.datetime); + const place = [ev.venueCity, ev.venueRegion, ev.venueCountry] + .filter(Boolean).join(', '); + return ( +
  • ev.url && shellOpen(ev.url).catch(() => {})} + role={ev.url ? 'button' : undefined} + tabIndex={ev.url ? 0 : undefined} + > + {parts && ( +
    +
    {parts.month}
    +
    {parts.day}
    +
    + )} +
    +
    {ev.venueName || place}
    +
    + {parts && ( + {parts.weekday}, {parts.time} + )} + {parts && place && } + {place} +
    +
    +
  • + ); + })} +
+ )} + {(hiddenTourCount > 0 || (showAllTours && tourEvents.length > TOUR_LIMIT)) && ( + + )} +
+ {t('nowPlayingInfo.poweredByBandsintown', 'Tour data via Bandsintown')} +
+
+ )} +
+ ); +} diff --git a/src/components/QueuePanel.tsx b/src/components/QueuePanel.tsx index 5cdabf8b..d4eb70cf 100644 --- a/src/components/QueuePanel.tsx +++ b/src/components/QueuePanel.tsx @@ -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() { }) )} - ) : ( + ) : activeTab === 'lyrics' ? ( + ) : ( + )}
@@ -717,6 +720,14 @@ export default function QueuePanel() { {t('player.lyrics')} +
{saveModalOpen && ( diff --git a/src/locales/de.ts b/src/locales/de.ts index 80c9aaa1..e0f5a298 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -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', diff --git a/src/locales/en.ts b/src/locales/en.ts index 824a7185..c49c200d 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -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', diff --git a/src/locales/es.ts b/src/locales/es.ts index 57c59e77..d39073fb 100644 --- a/src/locales/es.ts +++ b/src/locales/es.ts @@ -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', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 582696ee..b3816b2b 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -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', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index 21c03df5..9f6399f7 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -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', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index c4ad696a..321ea3d5 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -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', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 886407eb..b22aa975 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -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: 'Название', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index f8d87dc2..d25feb67 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -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: '标题', diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 7ba7c4a2..2d5331ec 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1817,6 +1817,34 @@ export default function Settings() { +
+
+
+
+ {t('settings.enableBandsintown')} + + + +
+
{t('settings.enableBandsintownDesc')}
+
+ +
diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 89dcea11..8bf49e0f 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -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()( minimizeToTray: false, discordRichPresence: false, enableAppleMusicCoversDiscord: false, + enableBandsintown: false, discordTemplateDetails: '{artist} - {title}', discordTemplateState: '{album}', discordTemplateLargeText: '{album}', @@ -454,6 +458,7 @@ export const useAuthStore = create()( 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 }), diff --git a/src/store/lyricsStore.ts b/src/store/lyricsStore.ts index 8793f7ad..34fb751e 100644 --- a/src/store/lyricsStore.ts +++ b/src/store/lyricsStore.ts @@ -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()((set) => ({ @@ -14,4 +15,5 @@ export const useLyricsStore = create()((set) => ({ setTab: (tab) => set({ activeTab: tab }), showLyrics: () => set({ activeTab: 'lyrics' }), showQueue: () => set({ activeTab: 'queue' }), + showInfo: () => set({ activeTab: 'info' }), })); diff --git a/src/styles/components.css b/src/styles/components.css index b0c35952..e76e0af8 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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); }