mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
feat: add ICY metadata and AzuraCast radio streaming support (#146)
feat: add ICY metadata and AzuraCast radio streaming support
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.34.6",
|
"version": "1.34.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "psysonic",
|
"name": "psysonic",
|
||||||
"version": "1.34.6",
|
"version": "1.34.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/dm-sans": "^5.2.8",
|
"@fontsource-variable/dm-sans": "^5.2.8",
|
||||||
"@fontsource-variable/figtree": "^5.2.10",
|
"@fontsource-variable/figtree": "^5.2.10",
|
||||||
|
|||||||
@@ -250,6 +250,146 @@ async fn fetch_url_bytes(url: String) -> Result<(Vec<u8>, String), String> {
|
|||||||
Ok((bytes.to_vec(), content_type))
|
Ok((bytes.to_vec(), content_type))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch a JSON API endpoint through Rust to bypass CORS/WebView networking restrictions.
|
||||||
|
/// Returns the response body as a UTF-8 string for parsing on the JS side.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fetch_json_url(url: String) -> Result<String, String> {
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.header("User-Agent", "psysonic/1.0")
|
||||||
|
.header("Accept", "application/json")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.error_for_status()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let text = resp.text().await.map_err(|e| e.to_string())?;
|
||||||
|
Ok(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ICY metadata response returned to the frontend.
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
struct IcyMetadata {
|
||||||
|
/// The `StreamTitle` from the inline ICY metadata block in the stream (e.g. `"Artist - Title"`).
|
||||||
|
stream_title: Option<String>,
|
||||||
|
/// Value of the `icy-name` response header.
|
||||||
|
icy_name: Option<String>,
|
||||||
|
/// Value of the `icy-genre` response header.
|
||||||
|
icy_genre: Option<String>,
|
||||||
|
/// Value of the `icy-url` response header.
|
||||||
|
icy_url: Option<String>,
|
||||||
|
/// Value of the `icy-description` response header.
|
||||||
|
icy_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch ICY in-stream metadata from a radio stream URL.
|
||||||
|
///
|
||||||
|
/// Sends a GET request with `Icy-MetaData: 1` and reads just enough bytes
|
||||||
|
/// (up to `icy-metaint` audio bytes plus the following metadata block) to
|
||||||
|
/// extract the `StreamTitle`. The connection is dropped as soon as the
|
||||||
|
/// first metadata chunk has been parsed, so bandwidth usage is minimal.
|
||||||
|
#[tauri::command]
|
||||||
|
async fn fetch_icy_metadata(url: String) -> Result<IcyMetadata, String> {
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
|
||||||
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let resp = client
|
||||||
|
.get(&url)
|
||||||
|
.header("Icy-MetaData", "1")
|
||||||
|
.header("User-Agent", "psysonic/1.0")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Harvest ICY headers before consuming the body.
|
||||||
|
let headers = resp.headers();
|
||||||
|
let icy_name = headers.get("icy-name").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||||
|
let icy_genre = headers.get("icy-genre").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||||
|
let icy_url = headers.get("icy-url").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||||
|
let icy_description = headers.get("icy-description").and_then(|v| v.to_str().ok()).map(str::to_string);
|
||||||
|
let metaint: Option<usize> = headers
|
||||||
|
.get("icy-metaint")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.and_then(|s| s.parse().ok());
|
||||||
|
|
||||||
|
// If the server doesn't advertise a metaint we can still return header info.
|
||||||
|
let Some(metaint) = metaint else {
|
||||||
|
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cap metaint at 64 KiB to avoid reading unreasonably large audio chunks.
|
||||||
|
let metaint = metaint.min(65_536);
|
||||||
|
let needed = metaint + 1; // +1 for the metadata-length byte
|
||||||
|
|
||||||
|
let mut buf: Vec<u8> = Vec::with_capacity(needed + 256);
|
||||||
|
let mut stream = resp.bytes_stream();
|
||||||
|
|
||||||
|
while buf.len() < needed {
|
||||||
|
let Some(chunk) = stream.next().await else { break };
|
||||||
|
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||||
|
buf.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
if buf.len() < needed {
|
||||||
|
// Stream ended before we reached the metadata block.
|
||||||
|
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The byte immediately after `metaint` audio bytes encodes metadata length:
|
||||||
|
// actual_bytes = length_byte * 16
|
||||||
|
let meta_len = buf[metaint] as usize * 16;
|
||||||
|
if meta_len == 0 {
|
||||||
|
return Ok(IcyMetadata { stream_title: None, icy_name, icy_genre, icy_url, icy_description });
|
||||||
|
}
|
||||||
|
|
||||||
|
// We may need to read a few more chunks to get the full metadata block.
|
||||||
|
let total_needed = needed + meta_len;
|
||||||
|
while buf.len() < total_needed {
|
||||||
|
let Some(chunk) = stream.next().await else { break };
|
||||||
|
let chunk = chunk.map_err(|e| e.to_string())?;
|
||||||
|
buf.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta_start = needed; // index of first metadata byte
|
||||||
|
let meta_end = (meta_start + meta_len).min(buf.len());
|
||||||
|
let meta_bytes = &buf[meta_start..meta_end];
|
||||||
|
|
||||||
|
// ICY metadata is Latin-1 encoded; convert to a Rust String lossily.
|
||||||
|
let meta_str: String = meta_bytes
|
||||||
|
.iter()
|
||||||
|
.map(|&b| if b == 0 { '\0' } else { b as char })
|
||||||
|
.collect::<String>();
|
||||||
|
|
||||||
|
// Parse StreamTitle='...' — value ends at the next unescaped single-quote.
|
||||||
|
let stream_title = meta_str
|
||||||
|
.split("StreamTitle='")
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|s| {
|
||||||
|
// Find closing quote that is NOT preceded by a backslash.
|
||||||
|
let mut prev = '\0';
|
||||||
|
let mut end = s.len();
|
||||||
|
for (i, c) in s.char_indices() {
|
||||||
|
if c == '\'' && prev != '\\' {
|
||||||
|
end = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prev = c;
|
||||||
|
}
|
||||||
|
let title = s[..end].trim().to_string();
|
||||||
|
if title.is_empty() { None } else { Some(title) }
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(IcyMetadata { stream_title, icy_name, icy_genre, icy_url, icy_description })
|
||||||
|
}
|
||||||
|
|
||||||
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
/// Proxy Last.fm API calls through Rust/reqwest to avoid WebView networking restrictions.
|
||||||
/// `params` is a list of [key, value] pairs (method must be included).
|
/// `params` is a list of [key, value] pairs (method must be included).
|
||||||
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
/// If `sign` is true an api_sig is computed. If `get` is true, a GET request is made.
|
||||||
@@ -1451,6 +1591,8 @@ pub fn run() {
|
|||||||
search_radio_browser,
|
search_radio_browser,
|
||||||
get_top_radio_stations,
|
get_top_radio_stations,
|
||||||
fetch_url_bytes,
|
fetch_url_bytes,
|
||||||
|
fetch_json_url,
|
||||||
|
fetch_icy_metadata,
|
||||||
download_track_offline,
|
download_track_offline,
|
||||||
delete_offline_track,
|
delete_offline_track,
|
||||||
get_offline_cache_size,
|
get_offline_cache_size,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import MarqueeText from './MarqueeText';
|
import MarqueeText from './MarqueeText';
|
||||||
import LastfmIcon from './LastfmIcon';
|
import LastfmIcon from './LastfmIcon';
|
||||||
|
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||||
|
|
||||||
function formatTime(seconds: number): string {
|
function formatTime(seconds: number): string {
|
||||||
if (!seconds || isNaN(seconds)) return '0:00';
|
if (!seconds || isNaN(seconds)) return '0:00';
|
||||||
@@ -44,6 +45,9 @@ export default function PlayerBar() {
|
|||||||
|
|
||||||
const isRadio = !!currentRadio;
|
const isRadio = !!currentRadio;
|
||||||
|
|
||||||
|
// Radio metadata (ICY or AzuraCast) — only active while a radio station is playing.
|
||||||
|
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
||||||
|
|
||||||
const isStarred = currentTrack
|
const isStarred = currentTrack
|
||||||
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
? (currentTrack.id in starredOverrides ? starredOverrides[currentTrack.id] : !!currentTrack.starred)
|
||||||
: false;
|
: false;
|
||||||
@@ -129,13 +133,23 @@ export default function PlayerBar() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="player-track-meta">
|
<div className="player-track-meta">
|
||||||
<MarqueeText
|
<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"
|
className="player-track-name"
|
||||||
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
|
style={{ cursor: !isRadio && currentTrack?.albumId ? 'pointer' : 'default' }}
|
||||||
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
onClick={() => !isRadio && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
|
||||||
/>
|
/>
|
||||||
<MarqueeText
|
<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"
|
className="player-track-artist"
|
||||||
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
style={{ cursor: !isRadio && currentTrack?.artistId ? 'pointer' : 'default' }}
|
||||||
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
onClick={() => !isRadio && currentTrack?.artistId && navigate(`/artist/${currentTrack.artistId}`)}
|
||||||
@@ -148,6 +162,11 @@ export default function PlayerBar() {
|
|||||||
ariaLabel={t('albumDetail.ratingLabel')}
|
ariaLabel={t('albumDetail.ratingLabel')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{isRadio && radioMeta.listeners != null && (
|
||||||
|
<span className="player-radio-listeners">
|
||||||
|
{t('radio.listenerCount', { count: radioMeta.listeners })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{currentTrack && !isRadio && (
|
{currentTrack && !isRadio && (
|
||||||
<button
|
<button
|
||||||
@@ -207,11 +226,28 @@ export default function PlayerBar() {
|
|||||||
<div className="player-waveform-section">
|
<div className="player-waveform-section">
|
||||||
{isRadio ? (
|
{isRadio ? (
|
||||||
<>
|
<>
|
||||||
<span className="player-time">{formatTime(currentTime)}</span>
|
{radioMeta.source === 'azuracast' && radioMeta.elapsed != null && radioMeta.duration != null && radioMeta.duration > 0 ? (
|
||||||
<div className="player-waveform-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<>
|
||||||
<span className="radio-live-badge">{t('radio.live')}</span>
|
<span className="player-time">{formatTime(radioMeta.elapsed)}</span>
|
||||||
</div>
|
<div className="player-waveform-wrap">
|
||||||
<span className="player-time" style={{ opacity: 0 }}>0:00</span>
|
<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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -920,6 +920,10 @@ export const deTranslation = {
|
|||||||
favorite: 'Zu Favoriten hinzufügen',
|
favorite: 'Zu Favoriten hinzufügen',
|
||||||
unfavorite: 'Aus Favoriten entfernen',
|
unfavorite: 'Aus Favoriten entfernen',
|
||||||
noFavorites: 'Keine Lieblingssender.',
|
noFavorites: 'Keine Lieblingssender.',
|
||||||
|
listenerCount_one: '{{count}} Hörer',
|
||||||
|
listenerCount_other: '{{count}} Hörer',
|
||||||
|
recentlyPlayed: 'Zuletzt gespielt',
|
||||||
|
upNext: 'Als Nächstes',
|
||||||
},
|
},
|
||||||
folderBrowser: {
|
folderBrowser: {
|
||||||
empty: 'Leerer Ordner',
|
empty: 'Leerer Ordner',
|
||||||
|
|||||||
@@ -921,6 +921,10 @@ export const enTranslation = {
|
|||||||
favorite: 'Add to favorites',
|
favorite: 'Add to favorites',
|
||||||
unfavorite: 'Remove from favorites',
|
unfavorite: 'Remove from favorites',
|
||||||
noFavorites: 'No favorite stations.',
|
noFavorites: 'No favorite stations.',
|
||||||
|
listenerCount_one: '{{count}} listener',
|
||||||
|
listenerCount_other: '{{count}} listeners',
|
||||||
|
recentlyPlayed: 'Recently Played',
|
||||||
|
upNext: 'Up Next',
|
||||||
},
|
},
|
||||||
folderBrowser: {
|
folderBrowser: {
|
||||||
empty: 'Empty folder',
|
empty: 'Empty folder',
|
||||||
|
|||||||
@@ -915,6 +915,10 @@ export const frTranslation = {
|
|||||||
favorite: 'Ajouter aux favoris',
|
favorite: 'Ajouter aux favoris',
|
||||||
unfavorite: 'Retirer des favoris',
|
unfavorite: 'Retirer des favoris',
|
||||||
noFavorites: 'Aucune station favorite.',
|
noFavorites: 'Aucune station favorite.',
|
||||||
|
listenerCount_one: '{{count}} auditeur',
|
||||||
|
listenerCount_other: '{{count}} auditeurs',
|
||||||
|
recentlyPlayed: 'Récemment joués',
|
||||||
|
upNext: 'À suivre',
|
||||||
},
|
},
|
||||||
folderBrowser: {
|
folderBrowser: {
|
||||||
empty: 'Dossier vide',
|
empty: 'Dossier vide',
|
||||||
|
|||||||
@@ -914,6 +914,10 @@ export const nbTranslation = {
|
|||||||
favorite: 'Legg til i favoritter',
|
favorite: 'Legg til i favoritter',
|
||||||
unfavorite: 'Fjern fra favoritter',
|
unfavorite: 'Fjern fra favoritter',
|
||||||
noFavorites: 'Ingen favorittstasjoner.',
|
noFavorites: 'Ingen favorittstasjoner.',
|
||||||
|
listenerCount_one: '{{count}} lytter',
|
||||||
|
listenerCount_other: '{{count}} lyttere',
|
||||||
|
recentlyPlayed: 'Nylig spilt',
|
||||||
|
upNext: 'Neste ut',
|
||||||
},
|
},
|
||||||
folderBrowser: {
|
folderBrowser: {
|
||||||
empty: 'Tom mappe',
|
empty: 'Tom mappe',
|
||||||
|
|||||||
@@ -915,6 +915,10 @@ export const nlTranslation = {
|
|||||||
favorite: 'Toevoegen aan favorieten',
|
favorite: 'Toevoegen aan favorieten',
|
||||||
unfavorite: 'Verwijderen uit favorieten',
|
unfavorite: 'Verwijderen uit favorieten',
|
||||||
noFavorites: 'Geen favoriete stations.',
|
noFavorites: 'Geen favoriete stations.',
|
||||||
|
listenerCount_one: '{{count}} luisteraar',
|
||||||
|
listenerCount_other: '{{count}} luisteraars',
|
||||||
|
recentlyPlayed: 'Recent gespeeld',
|
||||||
|
upNext: 'Volgende',
|
||||||
},
|
},
|
||||||
folderBrowser: {
|
folderBrowser: {
|
||||||
empty: 'Lege map',
|
empty: 'Lege map',
|
||||||
|
|||||||
@@ -974,6 +974,10 @@ export const ruTranslation = {
|
|||||||
favorite: 'В избранное',
|
favorite: 'В избранное',
|
||||||
unfavorite: 'Убрать из избранного',
|
unfavorite: 'Убрать из избранного',
|
||||||
noFavorites: 'Избранных станций нет.',
|
noFavorites: 'Избранных станций нет.',
|
||||||
|
listenerCount_one: '{{count}} слушатель',
|
||||||
|
listenerCount_other: '{{count}} слушателей',
|
||||||
|
recentlyPlayed: 'Недавно сыгранное',
|
||||||
|
upNext: 'Следующий',
|
||||||
},
|
},
|
||||||
folderBrowser: {
|
folderBrowser: {
|
||||||
empty: 'Папка пуста',
|
empty: 'Папка пуста',
|
||||||
|
|||||||
@@ -911,6 +911,10 @@ export const zhTranslation = {
|
|||||||
favorite: '添加到收藏',
|
favorite: '添加到收藏',
|
||||||
unfavorite: '从收藏移除',
|
unfavorite: '从收藏移除',
|
||||||
noFavorites: '没有收藏的电台。',
|
noFavorites: '没有收藏的电台。',
|
||||||
|
listenerCount_one: '{{count}} 位听众',
|
||||||
|
listenerCount_other: '{{count}} 位听众',
|
||||||
|
recentlyPlayed: '最近播放',
|
||||||
|
upNext: '即将播放',
|
||||||
},
|
},
|
||||||
folderBrowser: {
|
folderBrowser: {
|
||||||
empty: '空文件夹',
|
empty: '空文件夹',
|
||||||
|
|||||||
+129
-3
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, memo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { usePlayerStore } from '../store/playerStore';
|
||||||
import { useLyricsStore } from '../store/lyricsStore';
|
import { useLyricsStore } from '../store/lyricsStore';
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SubsonicSong, SubsonicArtistInfo,
|
SubsonicSong, SubsonicArtistInfo,
|
||||||
} from '../api/subsonic';
|
} from '../api/subsonic';
|
||||||
import { useCachedUrl } from '../components/CachedImage';
|
import { useCachedUrl } from '../components/CachedImage';
|
||||||
|
import { useRadioMetadata } from '../hooks/useRadioMetadata';
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -211,6 +212,7 @@ export default function NowPlaying() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const currentTrack = usePlayerStore(s => s.currentTrack);
|
const currentTrack = usePlayerStore(s => s.currentTrack);
|
||||||
|
const currentRadio = usePlayerStore(s => s.currentRadio);
|
||||||
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
const userRatingOverrides = usePlayerStore(s => s.userRatingOverrides);
|
||||||
const isPlaying = usePlayerStore(s => s.isPlaying);
|
const isPlaying = usePlayerStore(s => s.isPlaying);
|
||||||
const showLyrics = useLyricsStore(s => s.showLyrics);
|
const showLyrics = useLyricsStore(s => s.showLyrics);
|
||||||
@@ -220,6 +222,9 @@ export default function NowPlaying() {
|
|||||||
|
|
||||||
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
|
||||||
|
|
||||||
|
// Radio metadata (ICY or AzuraCast)
|
||||||
|
const radioMeta = useRadioMetadata(currentRadio ?? null);
|
||||||
|
|
||||||
// Extra song metadata
|
// Extra song metadata
|
||||||
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
const [songMeta, setSongMeta] = useState<SubsonicSong | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -259,15 +264,136 @@ export default function NowPlaying() {
|
|||||||
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
const coverKey = currentTrack?.coverArt ? coverArtCacheKey(currentTrack.coverArt, 800) : '';
|
||||||
const resolvedCover = useCachedUrl(coverFetchUrl, coverKey);
|
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 ?? [];
|
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 (
|
return (
|
||||||
<div className="np-page">
|
<div className="np-page">
|
||||||
|
|
||||||
<div className="np-main">
|
<div className="np-main">
|
||||||
{currentTrack ? (
|
{radioNowPlaying ? (
|
||||||
|
radioNowPlaying
|
||||||
|
) : currentTrack ? (
|
||||||
<>
|
<>
|
||||||
{/* ── Hero Card ── */}
|
{/* ── Hero Card ── */}
|
||||||
<div className="np-hero-card">
|
<div className="np-hero-card">
|
||||||
|
|||||||
@@ -4687,6 +4687,121 @@
|
|||||||
padding: 40px 0;
|
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 */
|
/* Queue section */
|
||||||
.np-queue-section {
|
.np-queue-section {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user