merge: integrate origin/main into feat/audiomuse-navidrome

This commit is contained in:
Maxim Isaev
2026-04-10 13:04:34 +03:00
16 changed files with 793 additions and 13 deletions
+9
View File
@@ -557,6 +557,7 @@ export default function App() {
const effectiveTheme = useThemeScheduler();
const font = useFontStore(s => s.font);
const uiScale = useFontStore(s => s.uiScale);
const setUiScale = useFontStore(s => s.setUiScale);
const [exportPickerOpen, setExportPickerOpen] = useState(false);
useEffect(() => {
@@ -567,6 +568,14 @@ export default function App() {
document.documentElement.setAttribute('data-font', font);
}, [font]);
// TODO(ui-scale): UI scaling is disabled pending a cross-platform rework.
// Reset any stored non-100% value so users aren't stuck at a broken scale.
// When re-enabling: remove this effect AND re-enable the slider in Settings.tsx.
useEffect(() => {
if (uiScale !== 1.0) setUiScale(1.0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
document.documentElement.style.zoom = String(uiScale);
}, [uiScale]);
+111
View File
@@ -0,0 +1,111 @@
import { invoke } from '@tauri-apps/api/core';
// ─── AzuraCast API types ──────────────────────────────────────────────────────
export interface AzuraCastSong {
artist: string;
title: string;
album: string;
art?: string;
text?: string; // "Artist - Title" combined
}
export interface AzuraCastNowPlayingTrack {
song: AzuraCastSong;
duration: number; // seconds
elapsed: number; // seconds played so far
remaining: number; // seconds remaining
played_at?: number;
}
export interface AzuraCastListeners {
current: number;
unique?: number;
total?: number;
}
export interface AzuraCastNowPlaying {
now_playing: AzuraCastNowPlayingTrack;
playing_next?: { song: AzuraCastSong } | null;
song_history: Array<{ song: AzuraCastSong; played_at?: number }>;
listeners: AzuraCastListeners;
station?: { name: string; shortcode: string };
}
// ─── Detection helpers ────────────────────────────────────────────────────────
/**
* Try to derive an AzuraCast NowPlaying API URL from a stream URL.
*
* AzuraCast stream URLs follow the pattern:
* https://<host>/listen/<shortcode>/<bitrate>.<ext>
*
* Returns the candidate API URL or `null` if the pattern doesn't match.
*/
export function guessAzuraCastApiUrl(streamUrl: string): string | null {
try {
const u = new URL(streamUrl);
const parts = u.pathname.split('/').filter(Boolean);
// Expect: ['listen', '<shortcode>', '<file>']
if (parts.length >= 2 && parts[0] === 'listen') {
const shortcode = parts[1];
return `${u.origin}/api/nowplaying/${shortcode}`;
}
} catch {
// ignore invalid URLs
}
return null;
}
/**
* Check whether a homepage URL itself looks like an AzuraCast NowPlaying
* API endpoint and return the canonical URL to use.
*
* Accepts:
* - https://<host>/api/nowplaying → all stations, we use as-is
* - https://<host>/api/nowplaying/<shortcode> → single station, use as-is
*/
export function normaliseAzuraCastHomepageUrl(homepageUrl: string): string | null {
try {
const u = new URL(homepageUrl);
if (/^\/api\/nowplaying(\/[^/]+)?$/.test(u.pathname)) {
return homepageUrl;
}
} catch {
// ignore
}
return null;
}
/**
* Fetch AzuraCast NowPlaying data from the given API URL (bypasses CORS via
* the Rust backend). Returns `null` if the request fails or the response
* does not look like a valid AzuraCast payload.
*
* When the API URL points to the `/api/nowplaying` (array) endpoint, the
* first item in the array is returned. Otherwise the single-object form is
* used directly.
*/
export async function fetchAzuraCastNowPlaying(apiUrl: string): Promise<AzuraCastNowPlaying | null> {
try {
const raw: string = await invoke('fetch_json_url', { url: apiUrl });
const parsed = JSON.parse(raw);
// If the response is an array (all-stations endpoint), take the first item.
const obj: unknown = Array.isArray(parsed) ? parsed[0] : parsed;
if (!obj || typeof obj !== 'object') return null;
const np = obj as Record<string, unknown>;
// Minimal validation: must have `now_playing` with a `song` inside.
if (
np.now_playing &&
typeof np.now_playing === 'object' &&
(np.now_playing as Record<string, unknown>).song
) {
return np as unknown as AzuraCastNowPlaying;
}
} catch {
// Network error, JSON parse error, etc.
}
return null;
}
+43 -7
View File
@@ -16,6 +16,7 @@ import { useNavigate } from 'react-router-dom';
import { useLyricsStore } from '../store/lyricsStore';
import MarqueeText from './MarqueeText';
import LastfmIcon from './LastfmIcon';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
function formatTime(seconds: number): string {
if (!seconds || isNaN(seconds)) return '0:00';
@@ -44,6 +45,9 @@ export default function PlayerBar() {
const isRadio = !!currentRadio;
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
const radioMeta = useRadioMetadata(currentRadio ?? null);
const isStarred = currentTrack
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
: false;
@@ -129,13 +133,23 @@ export default function PlayerBar() {
</div>
<div className="player-track-meta">
<MarqueeText
text={isRadio ? (currentRadio?.name ?? '—') : (currentTrack?.title ?? t('player.noTitle'))}
text={isRadio
? (radioMeta.currentTitle
? (radioMeta.currentArtist
? `${radioMeta.currentArtist}${radioMeta.currentTitle}`
: radioMeta.currentTitle)
: (currentRadio?.name ?? '—'))
: (currentTrack?.title ?? t('player.noTitle'))}
className="player-track-name"
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
/>
<MarqueeText
text={isRadio ? t('radio.liveStream') : (currentTrack?.artist ?? '—')}
text={isRadio
? (radioMeta.currentTitle && currentRadio?.name
? currentRadio.name
: t('radio.liveStream'))
: (currentTrack?.artist ?? '—')}
className="player-track-artist"
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
@@ -148,6 +162,11 @@ export default function PlayerBar() {
ariaLabel={t('albumDetail.ratingLabel')}
/>
)}
{isRadio && radioMeta.listeners != null && (
<span className="player-radio-listeners">
{t('radio.listenerCount', { count: radioMeta.listeners })}
</span>
)}
</div>
{currentTrack && !isRadio && (
<button
@@ -207,11 +226,28 @@ export default function PlayerBar() {
<div className="player-waveform-section">
{isRadio ? (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
<>
<span className="player-time">{formatTime(radioMeta.elapsed)}</span>
<div className="player-waveform-wrap">
<div className="radio-progress-bar">
<div
className="radio-progress-fill"
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
/>
</div>
</div>
<span className="player-time">{formatTime(radioMeta.duration)}</span>
</>
) : (
<>
<span className="player-time">{formatTime(currentTime)}</span>
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<span className="radio-live-badge">{t('radio.live')}</span>
</div>
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
</>
)}
</>
) : (
<>
+208
View File
@@ -0,0 +1,208 @@
import { useEffect, useRef, useState } from 'react';
import { invoke } from '@tauri-apps/api/core';
import type { InternetRadioStation } from '../api/subsonic';
import {
guessAzuraCastApiUrl,
normaliseAzuraCastHomepageUrl,
fetchAzuraCastNowPlaying,
type AzuraCastNowPlaying,
type AzuraCastSong,
} from '../api/azuracast';
// ─── Public types ─────────────────────────────────────────────────────────────
export type RadioMetadataSource = 'azuracast' | 'icy' | 'none';
export interface RadioHistoryItem {
song: AzuraCastSong;
playedAt?: number; // unix timestamp
}
export interface RadioMetadata {
/** Metadata source that is currently active. */
source: RadioMetadataSource;
/** Station name (from ICY icy-name or AzuraCast station.name). */
stationName?: string;
/** Current track title (combined or individual fields). */
currentTitle?: string;
currentArtist?: string;
currentAlbum?: string;
currentArt?: string;
/** AzuraCast-only: seconds elapsed in current track. */
elapsed?: number;
/** AzuraCast-only: total duration of current track in seconds. */
duration?: number;
/** AzuraCast-only: number of current listeners. */
listeners?: number;
/** AzuraCast-only: last N played tracks. */
history: RadioHistoryItem[];
/** AzuraCast-only: next track queued. */
nextSong?: AzuraCastSong;
}
// ─── ICY metadata interface (matches Rust IcyMetadata struct) ─────────────────
interface IcyMetadataResult {
stream_title?: string;
icy_name?: string;
icy_genre?: string;
icy_url?: string;
icy_description?: string;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseIcyStreamTitle(streamTitle: string): { artist?: string; title: string } {
const sep = streamTitle.indexOf(' - ');
if (sep !== -1) {
return { artist: streamTitle.slice(0, sep).trim(), title: streamTitle.slice(sep + 3).trim() };
}
return { title: streamTitle };
}
function nowPlayingToMetadata(np: AzuraCastNowPlaying): RadioMetadata {
const nowPlaying = np.now_playing;
const song = nowPlaying?.song;
return {
source: 'azuracast',
stationName: np.station?.name,
currentTitle: song?.title,
currentArtist: song?.artist,
currentAlbum: song?.album,
currentArt: song?.art,
elapsed: nowPlaying?.elapsed,
duration: nowPlaying?.duration,
listeners: np.listeners?.current,
history: (np.song_history ?? []).slice(0, 5).map(h => ({
song: h.song,
playedAt: h.played_at,
})),
nextSong: np.playing_next?.song ?? undefined,
};
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
const AZURACAST_POLL_MS = 15_000;
const ICY_POLL_MS = 30_000;
const EMPTY_METADATA: RadioMetadata = { source: 'none', history: [] };
export function useRadioMetadata(station: InternetRadioStation | null): RadioMetadata {
const [metadata, setMetadata] = useState<RadioMetadata>(EMPTY_METADATA);
// Keep elapsed in sync while AzuraCast is active: advance 1 s/tick while playing.
const elapsedRef = useRef<number | null>(null);
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stationRef = useRef<InternetRadioStation | null>(null);
// Store resolved AzuraCast API URL for the current station (or null).
const azuraCastUrlRef = useRef<string | null>(null);
// Stop the elapsed ticker.
function stopElapsedTick() {
if (elapsedIntervalRef.current) {
clearInterval(elapsedIntervalRef.current);
elapsedIntervalRef.current = null;
}
elapsedRef.current = null;
}
// Start a 1-second elapsed ticker that advances the stored elapsed value and
// updates the metadata state so the progress bar moves smoothly between polls.
function startElapsedTick(initial: number) {
stopElapsedTick();
elapsedRef.current = initial;
elapsedIntervalRef.current = setInterval(() => {
if (elapsedRef.current === null) return;
elapsedRef.current += 1;
setMetadata(prev =>
prev.source === 'azuracast'
? { ...prev, elapsed: elapsedRef.current! }
: prev
);
}, 1000);
}
useEffect(() => {
if (!station) {
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
return;
}
stationRef.current = station;
setMetadata(EMPTY_METADATA);
azuraCastUrlRef.current = null;
stopElapsedTick();
let cancelled = false;
let pollTimer: ReturnType<typeof setTimeout> | null = null;
// Determine which AzuraCast API URL to try, in priority order:
// 1. Homepage URL if it matches the /api/nowplaying[/shortcode] pattern
// 2. Guessed URL from stream URL path (/listen/<shortcode>/…)
const candidateApiUrl =
(station.homepageUrl ? normaliseAzuraCastHomepageUrl(station.homepageUrl) : null) ??
guessAzuraCastApiUrl(station.streamUrl);
async function pollAzuraCast(apiUrl: string) {
if (cancelled) return;
const np = await fetchAzuraCastNowPlaying(apiUrl);
if (cancelled) return;
if (np) {
const m = nowPlayingToMetadata(np);
setMetadata(m);
startElapsedTick(m.elapsed ?? 0);
pollTimer = setTimeout(() => pollAzuraCast(apiUrl), AZURACAST_POLL_MS);
} else {
// AzuraCast check failed — fall back to ICY
azuraCastUrlRef.current = null;
pollIcy();
}
}
async function pollIcy() {
if (cancelled) return;
const currentStation = stationRef.current;
if (!currentStation) return;
try {
const result: IcyMetadataResult = await invoke('fetch_icy_metadata', { url: currentStation.streamUrl });
if (cancelled) return;
if (result.stream_title || result.icy_name) {
const parsed = result.stream_title ? parseIcyStreamTitle(result.stream_title) : null;
setMetadata({
source: 'icy',
stationName: result.icy_name,
currentTitle: parsed?.title,
currentArtist: parsed?.artist,
history: [],
});
}
} catch {
// ICY metadata not available — leave empty metadata
}
if (!cancelled) {
pollTimer = setTimeout(pollIcy, ICY_POLL_MS);
}
}
// Kick off detection and polling.
if (candidateApiUrl) {
// Try AzuraCast first; fall back to ICY inside pollAzuraCast if it fails.
azuraCastUrlRef.current = candidateApiUrl;
pollAzuraCast(candidateApiUrl);
} else {
pollIcy();
}
return () => {
cancelled = true;
if (pollTimer) clearTimeout(pollTimer);
stopElapsedTick();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [station?.id, station?.streamUrl, station?.homepageUrl]);
return metadata;
}
+4
View File
@@ -927,6 +927,10 @@ export const deTranslation = {
favorite: 'Zu Favoriten hinzufügen',
unfavorite: 'Aus Favoriten entfernen',
noFavorites: 'Keine Lieblingssender.',
listenerCount_one: '{{count}} Hörer',
listenerCount_other: '{{count}} Hörer',
recentlyPlayed: 'Zuletzt gespielt',
upNext: 'Als Nächstes',
},
folderBrowser: {
empty: 'Leerer Ordner',
+4
View File
@@ -928,6 +928,10 @@ export const enTranslation = {
favorite: 'Add to favorites',
unfavorite: 'Remove from favorites',
noFavorites: 'No favorite stations.',
listenerCount_one: '{{count}} listener',
listenerCount_other: '{{count}} listeners',
recentlyPlayed: 'Recently Played',
upNext: 'Up Next',
},
folderBrowser: {
empty: 'Empty folder',
+4
View File
@@ -922,6 +922,10 @@ export const frTranslation = {
favorite: 'Ajouter aux favoris',
unfavorite: 'Retirer des favoris',
noFavorites: 'Aucune station favorite.',
listenerCount_one: '{{count}} auditeur',
listenerCount_other: '{{count}} auditeurs',
recentlyPlayed: 'Récemment joués',
upNext: 'À suivre',
},
folderBrowser: {
empty: 'Dossier vide',
+4
View File
@@ -921,6 +921,10 @@ export const nbTranslation = {
favorite: 'Legg til i favoritter',
unfavorite: 'Fjern fra favoritter',
noFavorites: 'Ingen favorittstasjoner.',
listenerCount_one: '{{count}} lytter',
listenerCount_other: '{{count}} lyttere',
recentlyPlayed: 'Nylig spilt',
upNext: 'Neste ut',
},
folderBrowser: {
empty: 'Tom mappe',
+4
View File
@@ -922,6 +922,10 @@ export const nlTranslation = {
favorite: 'Toevoegen aan favorieten',
unfavorite: 'Verwijderen uit favorieten',
noFavorites: 'Geen favoriete stations.',
listenerCount_one: '{{count}} luisteraar',
listenerCount_other: '{{count}} luisteraars',
recentlyPlayed: 'Recent gespeeld',
upNext: 'Volgende',
},
folderBrowser: {
empty: 'Lege map',
+4
View File
@@ -981,6 +981,10 @@ export const ruTranslation = {
favorite: 'В избранное',
unfavorite: 'Убрать из избранного',
noFavorites: 'Избранных станций нет.',
listenerCount_one: '{{count}} слушатель',
listenerCount_other: '{{count}} слушателей',
recentlyPlayed: 'Недавно сыгранное',
upNext: 'Следующий',
},
folderBrowser: {
empty: 'Папка пуста',
+4
View File
@@ -918,6 +918,10 @@ export const zhTranslation = {
favorite: '添加到收藏',
unfavorite: '从收藏移除',
noFavorites: '没有收藏的电台。',
listenerCount_one: '{{count}} 位听众',
listenerCount_other: '{{count}} 位听众',
recentlyPlayed: '最近播放',
upNext: '即将播放',
},
folderBrowser: {
empty: '空文件夹',
+129 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react';
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
@@ -11,6 +11,7 @@ import {
SubsonicSong, SubsonicArtistInfo,
} from '../api/subsonic';
import { useCachedUrl } from '../components/CachedImage';
import { useRadioMetadata } from '../hooks/useRadioMetadata';
// ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -212,6 +213,7 @@ export default function NowPlaying() {
const navigate = useNavigate();
const currentTrack = usePlayerStore(s => s.currentTrack);
const currentRadio = usePlayerStore(s => s.currentRadio);
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
const isPlaying = usePlayerStore(s => s.isPlaying);
const showLyrics = useLyricsStore(s => s.showLyrics);
@@ -224,6 +226,9 @@ export default function NowPlaying() {
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
// Radio metadata (ICY or AzuraCast)
const radioMeta = useRadioMetadata(currentRadio ?? null);
// Extra song metadata
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
useEffect(() => {
@@ -265,15 +270,136 @@ export default function NowPlaying() {
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
// Radio cover
const radioCoverFetchUrl = currentRadio?.coverArt ? buildCoverArtUrl(`ra-${currentRadio.id}`, 800) : '';
const radioCoverKey = currentRadio?.coverArt ? coverArtCacheKey(`ra-${currentRadio.id}`, 800) : '';
const resolvedRadioCover = useCachedUrl(radioCoverFetchUrl, radioCoverKey);
const similarArtists = artistInfo?.similarArtist ?? [];
// ── Radio now-playing section ────────────────────────────────────────────────
const radioNowPlaying = currentRadio && !currentTrack && (
<div className="np-radio-section">
{/* Station hero */}
<div className="np-hero-card">
<div className="np-hero-left">
<div className="np-hero-info">
<div className="np-title" style={{ color: 'var(--accent)' }}>
{currentRadio.name}
</div>
{radioMeta.currentTitle && (
<div className="np-artist-album">
{radioMeta.currentArtist && (
<><span className="np-link">{radioMeta.currentArtist}</span><span className="np-sep">·</span></>
)}
<span>{radioMeta.currentTitle}</span>
{radioMeta.currentAlbum && (
<><span className="np-sep">·</span><span style={{ opacity: 0.6 }}>{radioMeta.currentAlbum}</span></>
)}
</div>
)}
<div className="np-tech-row">
<span className="np-badge np-badge-live">
<Radio size={10} style={{ marginRight: 3 }} />{t('radio.live')}
</span>
{radioMeta.source === 'azuracast' && (
<span className="np-badge np-badge-azuracast">AzuraCast</span>
)}
{radioMeta.listeners != null && (
<span className="np-badge">
<Users size={10} style={{ marginRight: 3 }} />
{t('radio.listenerCount', { count: radioMeta.listeners })}
</span>
)}
</div>
{/* AzuraCast progress bar */}
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 && (
<div className="np-radio-progress-wrap">
<span className="np-radio-time">{formatTime(radioMeta.elapsed)}</span>
<div className="np-radio-progress-bar">
<div
className="np-radio-progress-fill"
style={{ width: `${Math.min(100, (radioMeta.elapsed / radioMeta.duration) * 100)}%` }}
/>
</div>
<span className="np-radio-time">{formatTime(radioMeta.duration)}</span>
</div>
)}
</div>
</div>
{/* Cover */}
<div className="np-hero-cover-wrap">
{resolvedRadioCover
? <img src={resolvedRadioCover} alt={currentRadio.name} className="np-cover" />
: radioMeta.currentArt
? <img src={radioMeta.currentArt} alt="" className="np-cover" onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
: <div className="np-cover np-cover-fallback"><Cast size={52} /></div>
}
</div>
{/* Placeholder to keep 3-column layout */}
<div style={{ flex: 1 }} />
</div>
{/* Upcoming track */}
{radioMeta.nextSong && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">
<SkipForward size={13} style={{ marginRight: 5 }} />{t('radio.upNext')}
</h3>
</div>
<div className="np-radio-next-track">
{radioMeta.nextSong.art && (
<img src={radioMeta.nextSong.art} alt="" className="np-radio-track-art"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
<div className="np-radio-track-info">
<span className="np-radio-track-title">{radioMeta.nextSong.title}</span>
{radioMeta.nextSong.artist && (
<span className="np-radio-track-artist">{radioMeta.nextSong.artist}</span>
)}
</div>
</div>
</div>
)}
{/* Song history */}
{radioMeta.history.length > 0 && (
<div className="np-info-card">
<div className="np-card-header">
<h3 className="np-card-title">
<Clock size={13} style={{ marginRight: 5 }} />{t('radio.recentlyPlayed')}
</h3>
</div>
<div className="np-album-tracklist">
{radioMeta.history.map((item, idx) => (
<div key={idx} className="np-album-track">
{item.song.art && (
<img src={item.song.art} alt="" className="np-radio-track-art np-radio-track-art--sm"
onError={e => { (e.target as HTMLImageElement).style.display = 'none'; }} />
)}
<span className="np-album-track-title truncate">
{item.song.artist ? `${item.song.artist}${item.song.title}` : item.song.title}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
return (
<div className="np-page">
<div className="np-main">
{currentTrack ? (
{radioNowPlaying ? (
radioNowPlaying
) : currentTrack ? (
<>
{/* ── Hero Card ── */}
<div className="np-hero-card">
+6 -1
View File
@@ -119,6 +119,7 @@ const CONTRIBUTORS = [
contributions: [
'Nightfox.nvim theme group in Open Source Classics (PR #114)',
'Switch reqwest to rustls-tls for cross-platform TLS (PR #112)',
'ICY stream metadata & AzuraCast Now Playing support (PR #146)',
],
},
] as const;
@@ -1336,7 +1337,11 @@ export default function Settings() {
<h2>{t('settings.uiScaleTitle')}</h2>
</div>
<div className="settings-card">
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
{/* TODO: UI scaling is being reworked — disabled until fixed */}
<p style={{ fontSize: 13, color: 'var(--text-secondary)', margin: 0 }}>
Interface scaling is currently being reworked and will be available in a future update.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', opacity: 0.4, pointerEvents: 'none', marginTop: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span style={{ fontSize: 13, color: 'var(--text-secondary)' }}>{t('settings.uiScaleLabel')}</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--accent)', minWidth: 40, textAlign: 'right' }}>
+115
View File
@@ -4687,6 +4687,121 @@
padding: 40px 0;
}
/* ─ Radio NowPlaying section ─ */
.np-radio-section {
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
}
.np-badge-live {
background: rgba(239, 68, 68, 0.25);
color: #f87171;
display: inline-flex;
align-items: center;
}
.np-badge-azuracast {
background: rgba(99, 102, 241, 0.25);
color: #a5b4fc;
}
.np-radio-progress-wrap {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.np-radio-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.5);
min-width: 32px;
}
.np-radio-progress-bar {
flex: 1;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.np-radio-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 1s linear;
}
.np-radio-next-track {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.np-radio-track-art {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
flex-shrink: 0;
}
.np-radio-track-art--sm {
width: 32px;
height: 32px;
}
.np-radio-track-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.np-radio-track-title {
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.np-radio-track-artist {
font-size: 11px;
color: var(--text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Radio progress bar in PlayerBar */
.radio-progress-bar {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
overflow: hidden;
}
.radio-progress-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
transition: width 1s linear;
}
/* Listener count in PlayerBar */
.player-radio-listeners {
font-size: 10px;
color: var(--text-muted);
margin-top: 1px;
}
/* Queue section */
.np-queue-section {
flex: 1;