Skip to content

Commit 02eef9c

Browse files
authored
Add remote HTTP API + web UI parity for the library (#82)
1 parent 6ec9e88 commit 02eef9c

4 files changed

Lines changed: 853 additions & 10 deletions

File tree

src/main.rs

Lines changed: 168 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1783,7 +1783,11 @@ impl App {
17831783
// ── Remote control ──────────────────────────────────────
17841784
if self.http_remote_running {
17851785
self.update_remote_state();
1786-
self.poll_remote_commands();
1786+
let t = self.poll_remote_commands();
1787+
// A returned task means the remote enqueued an op
1788+
// that needs to re-enter update() (Surprise / load /
1789+
// restore). Fire it before we return.
1790+
return t;
17871791
}
17881792
}
17891793

@@ -3928,6 +3932,23 @@ impl App {
39283932
fn update_remote_state(&self) {
39293933
if let Ok(mut rs) = self.remote_state.try_lock() {
39303934
let info = self.status.track_info.as_ref();
3935+
3936+
let current_md5 = self.playlist.current_entry().and_then(|e| e.md5.as_deref());
3937+
let is_favorite_current = current_md5
3938+
.map(|m| self.favorites.hashes.contains(m))
3939+
.unwrap_or(false);
3940+
3941+
// Sleep timer countdown — seconds until deadline. `None` when
3942+
// no timer is armed.
3943+
let sleep_remaining_secs = self
3944+
.sleep_deadline
3945+
.map(|dl| dl.saturating_duration_since(Instant::now()).as_secs() as u32);
3946+
3947+
let active_published_playlist = match &self.session_mode {
3948+
SessionMode::PublishedReadOnly { file } => Some(file.clone()),
3949+
_ => None,
3950+
};
3951+
39313952
rs.status = remote::RemoteStatus {
39323953
state: match self.status.state {
39333954
PlayState::Playing => "playing",
@@ -3951,11 +3972,33 @@ impl App {
39513972
sid_type: info.map(|i| i.sid_type.clone()).unwrap_or_default(),
39523973
is_pal: info.map(|i| i.is_pal).unwrap_or(true),
39533974
engine: self.config.output_engine.clone(),
3975+
is_favorite: is_favorite_current,
3976+
master_volume: self.config.master_volume,
3977+
shuffle: self.playlist.shuffle,
3978+
repeat: match self.playlist.repeat {
3979+
playlist::RepeatMode::Off => "off",
3980+
playlist::RepeatMode::All => "all",
3981+
playlist::RepeatMode::Single => "one",
3982+
}
3983+
.to_string(),
3984+
sleep_selected_mins: self.sleep_selected_mins,
3985+
sleep_remaining_secs,
3986+
hvsc_sync_active: self.hvsc_sync.is_some(),
3987+
hvsc_sync_progress: self.hvsc_sync_progress.map(|(done, total)| [done, total]),
3988+
active_published_playlist,
39543989
};
39553990

3956-
// Only rebuild playlist snapshot when entries change.
3957-
let version = self.playlist.len() as u64;
3991+
// Snapshot hvsc_root + published manifest for the library
3992+
// browse endpoints on the HTTP thread.
3993+
rs.hvsc_root = self.config.hvsc_root.clone().map(PathBuf::from);
3994+
rs.published_manifest = self.published_playlists_browser.manifest().cloned();
3995+
3996+
// Rebuild playlist snapshot when entries OR favourites change.
3997+
// We stuff both into one epoch so the version check catches both.
3998+
let favs_epoch = self.favorites.hashes.len() as u64;
3999+
let version = ((self.playlist.len() as u64) << 32) | favs_epoch;
39584000
if rs.playlist_version != version {
4001+
let fav_set = &self.favorites.hashes;
39594002
rs.playlist = self
39604003
.playlist
39614004
.entries
@@ -3968,6 +4011,11 @@ impl App {
39684011
duration: e.duration_secs,
39694012
num_sids: e.num_sids,
39704013
is_rsid: e.is_rsid,
4014+
is_favorite: e
4015+
.md5
4016+
.as_deref()
4017+
.map(|m| fav_set.contains(m))
4018+
.unwrap_or(false),
39714019
})
39724020
.collect();
39734021
rs.playlist_version = version;
@@ -3976,7 +4024,14 @@ impl App {
39764024
}
39774025

39784026
/// Process commands from the HTTP remote control server.
3979-
fn poll_remote_commands(&mut self) {
4027+
///
4028+
/// Returns a `Task<Message>` batch so commands that map to a full
4029+
/// `Message::…` handler (Surprise / LoadPublishedPlaylist / etc.)
4030+
/// can trigger the same code path the desktop UI uses. Simple ops
4031+
/// (favourites toggle, shuffle, volume) are executed in-line and
4032+
/// contribute `Task::none()` to the batch.
4033+
fn poll_remote_commands(&mut self) -> Task<Message> {
4034+
let mut tasks: Vec<Task<Message>> = Vec::new();
39804035
while let Ok(cmd) = self.remote_cmd_rx.try_recv() {
39814036
match cmd {
39824037
remote::RemoteCmd::PlayTrack(idx) => {
@@ -4006,6 +4061,115 @@ impl App {
40064061
let _ = self.cmd_tx.send(PlayerCmd::SetSubtune(n));
40074062
self.clear_advance_status();
40084063
}
4064+
4065+
// ── Playback QOL (in-line, no Task needed) ───────────
4066+
remote::RemoteCmd::ToggleFavorite(idx) => {
4067+
if let Some(md5) = self.playlist.entries.get(idx).and_then(|e| e.md5.clone()) {
4068+
let _ = self.favorites.toggle(&md5);
4069+
self.favorites.save();
4070+
}
4071+
}
4072+
remote::RemoteCmd::ToggleFavoriteCurrent => {
4073+
let md5 = self.playlist.current_entry().and_then(|e| e.md5.clone());
4074+
if let Some(md5) = md5 {
4075+
let _ = self.favorites.toggle(&md5);
4076+
self.favorites.save();
4077+
}
4078+
}
4079+
remote::RemoteCmd::ToggleShuffle => {
4080+
self.playlist.toggle_shuffle();
4081+
}
4082+
remote::RemoteCmd::CycleRepeat => {
4083+
self.playlist.repeat = self.playlist.repeat.cycle();
4084+
}
4085+
remote::RemoteCmd::SetSleepTimer(mins) => match mins {
4086+
Some(m) if m > 0 => {
4087+
self.sleep_deadline =
4088+
Some(Instant::now() + Duration::from_secs((m as u64) * 60));
4089+
self.sleep_selected_mins = Some(m);
4090+
}
4091+
_ => {
4092+
self.sleep_deadline = None;
4093+
self.sleep_selected_mins = None;
4094+
}
4095+
},
4096+
remote::RemoteCmd::SetVolume(v) => {
4097+
self.config.master_volume = v.clamp(0.0, 1.0);
4098+
self.config.save();
4099+
}
4100+
4101+
// ── Ops that reuse full Message handlers via Task::done
4102+
remote::RemoteCmd::Surprise => {
4103+
tasks.push(Task::done(Message::HvscBrowserSurpriseMe));
4104+
}
4105+
remote::RemoteCmd::LoadPublishedPlaylist(file) => {
4106+
tasks.push(Task::done(Message::PublishedPlaylistsLoad(file)));
4107+
}
4108+
remote::RemoteCmd::RestoreDefaultPlaylist => {
4109+
tasks.push(Task::done(Message::PublishedPlaylistsRestoreDefault));
4110+
}
4111+
4112+
// ── HVSC direct play / add by absolute path ──────────
4113+
// Reuses the same PlaylistEntry::from_path + add_entries +
4114+
// songlength chain as the HvscBrowserPlayTune handler.
4115+
remote::RemoteCmd::HvscPlay(path) => {
4116+
self.direct_hvsc_action(path, /*play=*/ true);
4117+
}
4118+
remote::RemoteCmd::HvscAdd(path) => {
4119+
self.direct_hvsc_action(path, /*play=*/ false);
4120+
}
4121+
}
4122+
}
4123+
if tasks.is_empty() {
4124+
Task::none()
4125+
} else {
4126+
Task::batch(tasks)
4127+
}
4128+
}
4129+
4130+
/// Realise a single SID at an absolute path (typically inside the
4131+
/// HVSC tree, but any path works), add it to the playlist, apply
4132+
/// songlengths, and optionally start playback. Shared by the two
4133+
/// remote `HvscPlay` / `HvscAdd` commands.
4134+
fn direct_hvsc_action(&mut self, path: PathBuf, play: bool) {
4135+
let entry = match playlist::PlaylistEntry::from_path(&path) {
4136+
Ok(e) => e,
4137+
Err(e) => {
4138+
eprintln!("[remote] HVSC direct action failed: {e}");
4139+
return;
4140+
}
4141+
};
4142+
let entry_path = entry.path.clone();
4143+
let song = entry.selected_song.max(1);
4144+
self.playlist.add_entries(vec![entry]);
4145+
if let Some(db) = self.songlength_db.as_ref() {
4146+
db.apply_to_playlist(
4147+
&mut self.playlist,
4148+
self.config.hvsc_root.as_deref().map(std::path::Path::new),
4149+
);
4150+
}
4151+
self.rebuild_filter();
4152+
if let Some(abs_i) = self
4153+
.playlist
4154+
.entries
4155+
.iter()
4156+
.position(|e| e.path == entry_path)
4157+
{
4158+
self.selected = Some(abs_i);
4159+
if play {
4160+
let _ = self.cmd_tx.send(player::PlayerCmd::Play {
4161+
path: entry_path,
4162+
song,
4163+
force_stereo: self.config.force_stereo_2sid
4164+
|| std::env::args().any(|a| a == "--stereo"),
4165+
sid4_addr: 0xd420,
4166+
audio_port: if self.config.u64_audio_enabled {
4167+
Some(self.config.u64_audio_port)
4168+
} else {
4169+
None
4170+
},
4171+
restart_usb_on_load: self.config.restart_usb_on_load,
4172+
});
40094173
}
40104174
}
40114175
}

src/published_playlists.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,12 @@ use std::path::PathBuf;
1313
use std::time::Duration;
1414

1515
use reqwest::Client;
16-
use serde::Deserialize;
16+
use serde::{Deserialize, Serialize};
1717

1818
const RAW_BASE_URL: &str = "https://raw.githubusercontent.com/sandlbn/Phosphor/main/playlists";
1919
const FETCH_TIMEOUT: Duration = Duration::from_secs(30);
2020

21-
#[derive(Debug, Clone, Deserialize)]
21+
#[derive(Debug, Clone, Deserialize, Serialize)]
2222
pub struct PublishedPlaylistMeta {
2323
pub file: String,
2424
pub name: String,
@@ -30,7 +30,7 @@ pub struct PublishedPlaylistMeta {
3030
pub sha256: String,
3131
}
3232

33-
#[derive(Debug, Clone, Deserialize)]
33+
#[derive(Debug, Clone, Deserialize, Serialize)]
3434
pub struct Manifest {
3535
#[serde(default)]
3636
pub version: u32,

src/published_playlists_browser.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ impl PublishedPlaylistsBrowser {
5353
.unwrap_or(&[])
5454
}
5555

56+
pub fn manifest(&self) -> Option<&Manifest> {
57+
self.manifest.as_ref()
58+
}
59+
5660
pub fn sync_in_flight(&self) -> bool {
5761
self.sync_in_flight
5862
}

0 commit comments

Comments
 (0)