Skip to content

Commit 7080cef

Browse files
program247365claude
andcommitted
feat(now-playing): album art in header, full-width visualizer, real HypeM art
Relocate the album art into the top header box, left-aligned with the Now Playing metadata, and let the scatter visualizer span the full width again (it previously gave up a 34-col left gutter to the art). Fullscreen mode is unchanged. The art column is sized to the cover's actual rendered width — derived from its source aspect ratio and the terminal's cell pixel size — so the metadata sits a consistent gap away whether the cover is square (Spotify) or 16:9 (YouTube/SoundCloud), rather than floating after a fixed-width column. Fix and extend artwork resolution for stream-first services: - SoundCloud: `fetch_thumbnail` passed `--print id`, which implies yt-dlp's `--simulate` and suppresses all file writes — so `--write-thumbnail` silently wrote nothing. Add `--no-simulate` to restore the write while keeping the printed id. - HypeM: yt-dlp's extractor exposes no thumbnail field at all, so scrape the track page's stable Open Graph `og:image` (via a small blocking reqwest call) to recover the real cover. - Any track that still has no art falls back to the bundled cover, so the header art box renders consistently instead of collapsing to the compact layout. reqwest is added with default-features off + native-tls only to reuse the TLS backend stream-download already pulls and avoid dragging rustls/ring, which would conflict with librespot's pinned getrandom 0.2.6. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 525c6ff commit 7080cef

7 files changed

Lines changed: 213 additions & 51 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
2424
serde_json = "1"
2525
spectrum-analyzer = "1.7"
2626
stream-download = { version = "0.22", features = ["process"] }
27+
# Blocking HTTP client for the HypeM artwork scrape (its yt-dlp extractor
28+
# exposes no thumbnail). Uses the same native-tls backend already pulled in by
29+
# stream-download's reqwest, so `blocking` is purely additive.
30+
reqwest = { version = "0.12", default-features = false, features = ["blocking", "native-tls"] }
2731
symphonia = { version = "0.5", default-features = false }
2832
rodio = { version = "0.22", default-features = false, features = ["playback", "mp3", "wav", "flac", "vorbis"] }
2933
souvlaki = { version = "0.8", default-features = false, features = ["use_zbus"] }

src/play_loop.rs

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,12 @@ use crate::audio::AudioPlayer;
2727
use crate::download::{DownloadEvent, LoadingPhase};
2828
use crate::media_controls::MediaSessionHandle;
2929
use crate::playback_input::PlaybackInput;
30-
use crate::plugin::{self, ytdlp, TrackInfo};
30+
use crate::plugin::{self, hypem, ytdlp, TrackInfo};
3131
use crate::storage::{track_record, HistorySortField, SharedStorage, Storage, SyncWarning};
3232
use crate::tui::{
3333
draw, draw_history_browser, draw_replay_error, draw_startup, restore_terminal, setup_terminal,
34-
AppState, HistoryPanelState, ProgressTrack, StartupProgressState, StartupScreenState, N_BANDS,
34+
AppState, HistoryPanelState, ProgressTrack, StartupProgressState, StartupScreenState,
35+
HEADER_ART_ROWS, N_BANDS,
3536
};
3637

3738
const SEEK_STEP: Duration = Duration::from_secs(5);
@@ -199,11 +200,13 @@ fn browse_history_session(
199200
}
200201
}
201202

202-
/// Path to the bundled fallback album cover for local files, which have no
203-
/// embedded art. The PNG is embedded in the binary and materialized into the
204-
/// cache on first use, so a Homebrew-installed binary needs no external asset.
205-
/// Best-effort: `None` if it can't be written (then the cover is simply blank).
206-
fn local_file_cover() -> Option<std::path::PathBuf> {
203+
/// Path to the bundled fallback album cover, used for any track with no
204+
/// artwork — local files (no embedded art) and remote sources whose extractor
205+
/// exposes no thumbnail (e.g. HypeM: yt-dlp can play it but its extractor
206+
/// returns no `thumbnail` field). The PNG is embedded in the binary and
207+
/// materialized into the cache on first use, so a Homebrew-installed binary
208+
/// needs no external asset. Best-effort: `None` if it can't be written.
209+
fn fallback_cover() -> Option<std::path::PathBuf> {
207210
const COVER: &[u8] = include_bytes!("../assets/local-cover.png");
208211
let path = plugin::cache_dir_path().ok()?.join("local-cover.png");
209212
if !path.exists() {
@@ -266,7 +269,7 @@ fn play_file_session(
266269
service: None,
267270
// Local files have no embedded art; use the bundled fallback
268271
// cover so the macOS Now Playing widget is never blank.
269-
thumbnail_path: local_file_cover(),
272+
thumbnail_path: fallback_cover(),
270273
is_live: false,
271274
collection: None,
272275
artist: None,
@@ -1188,16 +1191,31 @@ fn loop_playlist(
11881191
}
11891192
}
11901193

1191-
/// Open and decode a thumbnail image into a ratatui-image stateful protocol.
1194+
/// Open and decode a thumbnail image into a ratatui-image stateful protocol,
1195+
/// plus the terminal-column width it will occupy at [`HEADER_ART_ROWS`] tall.
11921196
/// Returns None if any step fails — the renderer treats that as "no image."
1197+
///
1198+
/// The width is derived from the source aspect ratio and the terminal's
1199+
/// cell pixel size: at a fixed cell height, `cols = rows · (cell_h/cell_w) ·
1200+
/// (img_w/img_h)`. Sizing the art column to this lets the header metadata sit
1201+
/// flush against the cover regardless of square vs. 16:9 art.
11931202
fn decode_thumbnail(
11941203
picker: Option<&Picker>,
11951204
path: Option<&Path>,
1196-
) -> Option<ratatui_image::protocol::StatefulProtocol> {
1205+
) -> Option<(ratatui_image::protocol::StatefulProtocol, u16)> {
11971206
let picker = picker?;
11981207
let path = path?;
11991208
let dyn_img = image::ImageReader::open(path).ok()?.decode().ok()?;
1200-
Some(picker.new_resize_protocol(dyn_img))
1209+
let aspect = if dyn_img.height() > 0 {
1210+
dyn_img.width() as f32 / dyn_img.height() as f32
1211+
} else {
1212+
1.0
1213+
};
1214+
let (cell_w, cell_h) = picker.font_size();
1215+
let cols = (HEADER_ART_ROWS as f32 * (cell_h as f32 / cell_w as f32) * aspect)
1216+
.round()
1217+
.clamp(1.0, 40.0) as u16;
1218+
Some((picker.new_resize_protocol(dyn_img), cols))
12011219
}
12021220

12031221
fn play_single_track(
@@ -1255,7 +1273,18 @@ fn play_single_track(
12551273
}
12561274
let is_favorite = storage.lock().unwrap().favorite_for(&record.track_key)?;
12571275

1258-
let thumbnail = decode_thumbnail(picker, track.thumbnail_path.as_deref());
1276+
// Any track that still has no artwork (e.g. HypeM, whose yt-dlp extractor
1277+
// exposes no thumbnail) falls back to the bundled cover so the header art
1278+
// box renders consistently rather than collapsing to the compact layout.
1279+
if track.thumbnail_path.is_none() {
1280+
track.thumbnail_path = fallback_cover();
1281+
}
1282+
1283+
let (thumbnail, thumbnail_cols) = match decode_thumbnail(picker, track.thumbnail_path.as_deref())
1284+
{
1285+
Some((protocol, cols)) => (Some(protocol), cols),
1286+
None => (None, 0),
1287+
};
12591288

12601289
let mut state = AppState {
12611290
filename: track.title.clone(),
@@ -1281,6 +1310,7 @@ fn play_single_track(
12811310
history_panel: None,
12821311
sync_warning: sync_warning.cloned(),
12831312
thumbnail,
1313+
thumbnail_cols,
12841314
is_live: track.is_live,
12851315
progress_track: None,
12861316
scrub_preview: None,
@@ -1452,7 +1482,10 @@ fn prepare_track_for_playback(
14521482
{
14531483
if let Some(source_url) = track.source_url.clone() {
14541484
if let Ok(cache_dir) = plugin::cache_dir_path() {
1455-
track.thumbnail_path = ytdlp::fetch_thumbnail(&source_url, &cache_dir);
1485+
// yt-dlp covers SoundCloud; HypeM's extractor has no thumbnail,
1486+
// so fall back to scraping its page's og:image.
1487+
track.thumbnail_path = ytdlp::fetch_thumbnail(&source_url, &cache_dir)
1488+
.or_else(|| hypem::fetch_thumbnail(&source_url, &cache_dir));
14561489
}
14571490
}
14581491
}
@@ -1730,6 +1763,7 @@ mod tests {
17301763
history_panel: None,
17311764
sync_warning: None,
17321765
thumbnail: None,
1766+
thumbnail_cols: 0,
17331767
is_live: false,
17341768
progress_track: None,
17351769
scrub_preview: None,

src/plugin/hypem.rs

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
use color_eyre::eyre::Result;
2-
use std::path::Path;
2+
use std::path::{Path, PathBuf};
3+
use std::time::Duration;
34

45
use super::{ytdlp, ServicePlugin, TrackInfo};
56

7+
const BROWSER_UA: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \
8+
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36";
9+
610
pub struct HypeM;
711

812
impl ServicePlugin for HypeM {
@@ -22,3 +26,99 @@ impl ServicePlugin for HypeM {
2226
Ok(tracks)
2327
}
2428
}
29+
30+
/// Fetch a HypeM track's artwork and cache it, returning the cached path.
31+
///
32+
/// yt-dlp's HypeM extractor returns no `thumbnail` field, so the normal
33+
/// `ytdlp::fetch_thumbnail` path produces nothing. HypeM track pages still
34+
/// carry a stable Open Graph `og:image` (used for social-share previews) whose
35+
/// URL we can't construct from the id alone (the storage shard isn't derivable),
36+
/// so we read the page and extract it. Returns `None` for non-HypeM URLs or any
37+
/// failure — callers treat that as "no art" and fall back to the bundled cover.
38+
pub fn fetch_thumbnail(url: &str, cache_dir: &Path) -> Option<PathBuf> {
39+
let id = track_id(url)?;
40+
let client = reqwest::blocking::Client::builder()
41+
.user_agent(BROWSER_UA)
42+
.timeout(Duration::from_secs(10))
43+
.build()
44+
.ok()?;
45+
let html = client.get(url).send().ok()?.error_for_status().ok()?.text().ok()?;
46+
let image_url = extract_og_image(&html)?;
47+
let bytes = client
48+
.get(&image_url)
49+
.send()
50+
.ok()?
51+
.error_for_status()
52+
.ok()?
53+
.bytes()
54+
.ok()?;
55+
let path = cache_dir.join(format!("hypem_{id}.jpg"));
56+
std::fs::write(&path, &bytes).ok()?;
57+
Some(path)
58+
}
59+
60+
/// Extracts the HypeM track id from a `hypem.com/track/<id>` URL. Ids are five
61+
/// lowercase-alphanumeric chars; returns `None` for non-HypeM/non-track URLs.
62+
fn track_id(url: &str) -> Option<String> {
63+
if !url.to_ascii_lowercase().contains("hypem.com") {
64+
return None;
65+
}
66+
let after = url.split("/track/").nth(1)?;
67+
let id: String = after
68+
.chars()
69+
.take_while(|c| c.is_ascii_alphanumeric())
70+
.collect();
71+
(id.len() == 5).then_some(id)
72+
}
73+
74+
/// Pulls the `og:image` URL out of a page's `<meta>` tags, tolerating either
75+
/// attribute order (`property` before or after `content`).
76+
fn extract_og_image(html: &str) -> Option<String> {
77+
html.split("<meta")
78+
.find(|tag| tag.contains("og:image"))
79+
.and_then(|tag| {
80+
let start = tag.find("content=\"")? + "content=\"".len();
81+
let rest = &tag[start..];
82+
let end = rest.find('"')?;
83+
Some(rest[..end].to_string())
84+
})
85+
}
86+
87+
#[cfg(test)]
88+
mod tests {
89+
use super::*;
90+
91+
#[test]
92+
fn parses_track_id() {
93+
assert_eq!(
94+
track_id("https://hypem.com/track/39jtp/Artist+-+Song").as_deref(),
95+
Some("39jtp")
96+
);
97+
assert_eq!(track_id("https://hypem.com/track/39jtp").as_deref(), Some("39jtp"));
98+
assert_eq!(track_id("https://soundcloud.com/foo/bar"), None);
99+
assert_eq!(track_id("https://hypem.com/popular"), None);
100+
}
101+
102+
#[test]
103+
fn extracts_og_image_property_first() {
104+
let html = r#"<meta property="og:image" content="https://static.hypem.com/x_500.jpg">"#;
105+
assert_eq!(
106+
extract_og_image(html).as_deref(),
107+
Some("https://static.hypem.com/x_500.jpg")
108+
);
109+
}
110+
111+
#[test]
112+
fn extracts_og_image_content_first() {
113+
let html = r#"<meta content="https://static.hypem.com/y_500.jpg" property="og:image" />"#;
114+
assert_eq!(
115+
extract_og_image(html).as_deref(),
116+
Some("https://static.hypem.com/y_500.jpg")
117+
);
118+
}
119+
120+
#[test]
121+
fn no_og_image_returns_none() {
122+
assert_eq!(extract_og_image("<html><head></head></html>"), None);
123+
}
124+
}

src/plugin/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
55

66
use crate::playback_input::{PendingDownload, PlaybackInput};
77

8-
mod hypem;
8+
pub mod hypem;
99
mod soundcloud;
1010
mod youtube;
1111
pub mod ytdlp;

src/plugin/ytdlp.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,10 @@ pub fn fetch_thumbnail(url: &str, cache_dir: &Path) -> Option<PathBuf> {
245245
let output_template = cache_dir.join("%(id)s.%(ext)s");
246246
let output = Command::new("yt-dlp")
247247
.arg("--skip-download")
248+
// `--print` implies `--simulate`, which suppresses ALL file writes
249+
// (including `--write-thumbnail`). `--no-simulate` re-enables the write
250+
// while still printing the id we use to locate the cached file.
251+
.arg("--no-simulate")
248252
.arg("--write-thumbnail")
249253
.arg("--convert-thumbnails")
250254
.arg("jpg")

0 commit comments

Comments
 (0)