Baseline: full tree snapshot after git repository loss
The previous .git directory was found gutted (only info/ remained), so history is unrecoverable locally. This commit restores version control with the tree exactly as it stands: the working app, the preview-tracks launcher on the artist hero, the scrollable-track-previews design bundle (docs + approved mockups), and the Phase 0 validation artifacts.
This commit is contained in:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -0,0 +1,445 @@
|
||||
//! Playback orchestration: bridges the audio thread, the streaming cache,
|
||||
//! and the webview.
|
||||
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
static CACHE_PRUNE_LOCK: Mutex<()> = Mutex::new(());
|
||||
use std::time::{Duration, Instant};
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
use player::{Player, PlayerEvent, StreamProgress};
|
||||
|
||||
// The audio thread keeps its responsive 60 ms command/end-detection loop, but
|
||||
// the webview does not need 16-17 IPC/reactivity updates per second to move a
|
||||
// progress indicator. Roughly four to five updates per second remain visually
|
||||
// smooth while letting Chromium spend most playback time asleep.
|
||||
const UI_POSITION_INTERVAL: Duration = Duration::from_millis(200);
|
||||
const WAVEFORM_CACHE_MAX_ENTRIES: usize = 256;
|
||||
const PROGRESSIVE_WAVEFORM_CACHE_MAX_ENTRIES: usize = 64;
|
||||
const WAVEFORM_JOB_MAX_IDLE_ENTRIES: usize = 256;
|
||||
|
||||
/// Tiny insertion-order LRU used for decoded waveform data. Waveforms are
|
||||
/// cheap individually, but a path-keyed session cache must not grow with every
|
||||
/// track ever visited.
|
||||
struct WaveformLru<T> {
|
||||
entries: HashMap<String, T>,
|
||||
order: VecDeque<String>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl<T: Clone> WaveformLru<T> {
|
||||
fn new(capacity: usize) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
fn get(&mut self, key: &str) -> Option<T> {
|
||||
let value = self.entries.get(key)?.clone();
|
||||
self.touch(key);
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: String, value: T) {
|
||||
self.entries.insert(key.clone(), value);
|
||||
self.touch(&key);
|
||||
while self.entries.len() > self.capacity {
|
||||
let Some(oldest) = self.order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
self.entries.remove(&oldest);
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, key: &str) {
|
||||
self.entries.remove(key);
|
||||
if let Some(index) = self.order.iter().position(|candidate| candidate == key) {
|
||||
self.order.remove(index);
|
||||
}
|
||||
}
|
||||
|
||||
fn touch(&mut self, key: &str) {
|
||||
if let Some(index) = self.order.iter().position(|candidate| candidate == key) {
|
||||
self.order.remove(index);
|
||||
}
|
||||
self.order.push_back(key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ProgressiveWaveform {
|
||||
decoded_bytes: u64,
|
||||
next_decode_bytes: u64,
|
||||
peaks: Vec<f32>,
|
||||
}
|
||||
|
||||
impl ProgressiveWaveform {
|
||||
pub(crate) fn new(decoded_bytes: u64, total_bytes: u64, peaks: Vec<f32>) -> Self {
|
||||
Self {
|
||||
decoded_bytes,
|
||||
next_decode_bytes: next_progressive_waveform_threshold(decoded_bytes, total_bytes),
|
||||
peaks,
|
||||
}
|
||||
}
|
||||
|
||||
/// Recompute only after the growing file has at least doubled. If a
|
||||
/// transfer restarted and the file shrank, force a fresh decode instead of
|
||||
/// returning peaks from bytes that no longer exist.
|
||||
pub(crate) fn should_recompute(&self, available_bytes: u64) -> bool {
|
||||
available_bytes < self.decoded_bytes
|
||||
|| (available_bytes > self.decoded_bytes && available_bytes >= self.next_decode_bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn peaks(&self) -> Vec<f32> {
|
||||
self.peaks.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// When the next partial re-decode is due. Early on the file doubles between
|
||||
/// decodes — they're cheap and the shape fills in fast. But pure doubling made
|
||||
/// the LAST gap span half the transfer (50% → 100%), so the drawn wave sat up
|
||||
/// to 2× behind the buffer and the playhead visibly walked off its end
|
||||
/// (decoded audio is always ahead of playback, the DRAWING wasn't). Past the
|
||||
/// eighth-of-file mark the schedule switches to fixed total/8 steps: the wave
|
||||
/// is never more than 12.5% behind the transfer, for at most ~7 extra decodes
|
||||
/// spread across the download.
|
||||
fn next_progressive_waveform_threshold(decoded_bytes: u64, total_bytes: u64) -> u64 {
|
||||
let doubled = decoded_bytes
|
||||
.saturating_mul(2)
|
||||
.max(decoded_bytes.saturating_add(1));
|
||||
let linear_step = decoded_bytes.saturating_add((total_bytes / 8).max(1));
|
||||
doubled
|
||||
.min(linear_step)
|
||||
.min(total_bytes.max(decoded_bytes.saturating_add(1)))
|
||||
}
|
||||
|
||||
pub struct PlayerState {
|
||||
pub player: Player,
|
||||
/// Active stream downloads: download id → progress shared with the player.
|
||||
pub streams: Mutex<HashMap<u64, Arc<StreamProgress>>>,
|
||||
/// Cache target for each active stream. This lets a library save adopt an
|
||||
/// in-flight stream instead of requesting the same peer/path again.
|
||||
pub stream_caches: Mutex<HashMap<u64, PathBuf>>,
|
||||
/// Bounded completed-waveform LRU, keyed by file path + bucket count.
|
||||
waveforms: Mutex<WaveformLru<Vec<f32>>>,
|
||||
/// Last useful partial decode per active/recent stream. A growing file is
|
||||
/// decoded again only after its byte length doubles.
|
||||
progressive_waveforms: Mutex<WaveformLru<ProgressiveWaveform>>,
|
||||
/// Per-key single-flight locks. The owned guard moves into the blocking
|
||||
/// decoder so cancellation of the IPC future cannot expose overlapping
|
||||
/// work while that decoder is still running.
|
||||
waveform_jobs: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
/// Pre-buffered next-track downloads: download id → cache target.
|
||||
/// Their events are swallowed (no UI, no library pipeline); play_stream
|
||||
/// attaches to them instead of restarting the transfer.
|
||||
pub preloads: Mutex<HashMap<u64, PathBuf>>,
|
||||
/// Transfers whose completed cache file should also be organized into the
|
||||
/// library. A save can subscribe to a stream/preload already in progress.
|
||||
pub deferred_saves: Mutex<HashMap<u64, PathBuf>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(tag = "kind", rename_all = "kebab-case")]
|
||||
enum PlayerUiEvent {
|
||||
Buffering,
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
TrackEnded { gapless: bool },
|
||||
Position { secs: f64, duration: Option<f64> },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// Create the audio thread and forward its events to the webview.
|
||||
pub fn init(app: &tauri::AppHandle) {
|
||||
let output_device = crate::config::load(app).audio_output_device;
|
||||
let (player, events) = Player::spawn_with_device(output_device);
|
||||
app.manage(PlayerState {
|
||||
player,
|
||||
streams: Mutex::new(HashMap::new()),
|
||||
stream_caches: Mutex::new(HashMap::new()),
|
||||
waveforms: Mutex::new(WaveformLru::new(WAVEFORM_CACHE_MAX_ENTRIES)),
|
||||
progressive_waveforms: Mutex::new(WaveformLru::new(PROGRESSIVE_WAVEFORM_CACHE_MAX_ENTRIES)),
|
||||
waveform_jobs: Mutex::new(HashMap::new()),
|
||||
preloads: Mutex::new(HashMap::new()),
|
||||
deferred_saves: Mutex::new(HashMap::new()),
|
||||
});
|
||||
|
||||
let handle = app.clone();
|
||||
std::thread::Builder::new()
|
||||
.name("player-events".into())
|
||||
.spawn(move || {
|
||||
let mut last_position_emit = Instant::now() - UI_POSITION_INTERVAL;
|
||||
for ev in events {
|
||||
let ui = match ev {
|
||||
PlayerEvent::Buffering => {
|
||||
last_position_emit = Instant::now() - UI_POSITION_INTERVAL;
|
||||
PlayerUiEvent::Buffering
|
||||
}
|
||||
PlayerEvent::Playing => {
|
||||
last_position_emit = Instant::now() - UI_POSITION_INTERVAL;
|
||||
PlayerUiEvent::Playing
|
||||
}
|
||||
PlayerEvent::Paused => PlayerUiEvent::Paused,
|
||||
PlayerEvent::Stopped => PlayerUiEvent::Stopped,
|
||||
PlayerEvent::TrackEnded { gapless } => {
|
||||
last_position_emit = Instant::now() - UI_POSITION_INTERVAL;
|
||||
PlayerUiEvent::TrackEnded { gapless }
|
||||
}
|
||||
PlayerEvent::Position { secs, duration } => {
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_position_emit) < UI_POSITION_INTERVAL {
|
||||
continue;
|
||||
}
|
||||
last_position_emit = now;
|
||||
PlayerUiEvent::Position { secs, duration }
|
||||
}
|
||||
PlayerEvent::Error(message) => PlayerUiEvent::Error { message },
|
||||
};
|
||||
let _ = handle.emit("soulseek:player", ui);
|
||||
}
|
||||
})
|
||||
.expect("spawn player event forwarder");
|
||||
}
|
||||
|
||||
impl PlayerState {
|
||||
pub(crate) fn cached_waveform(&self, key: &str) -> Option<Vec<f32>> {
|
||||
self.waveforms.lock().unwrap().get(key)
|
||||
}
|
||||
|
||||
pub(crate) fn cache_waveform(&self, key: String, peaks: Vec<f32>) {
|
||||
self.waveforms.lock().unwrap().insert(key, peaks);
|
||||
}
|
||||
|
||||
pub(crate) fn remove_cached_waveform(&self, key: &str) {
|
||||
self.waveforms.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
pub(crate) fn progressive_waveform(&self, key: &str) -> Option<ProgressiveWaveform> {
|
||||
self.progressive_waveforms.lock().unwrap().get(key)
|
||||
}
|
||||
|
||||
pub(crate) fn cache_progressive_waveform(&self, key: String, waveform: ProgressiveWaveform) {
|
||||
self.progressive_waveforms
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(key, waveform);
|
||||
}
|
||||
|
||||
pub(crate) fn remove_progressive_waveform(&self, key: &str) {
|
||||
self.progressive_waveforms.lock().unwrap().remove(key);
|
||||
}
|
||||
|
||||
pub(crate) fn waveform_job(&self, key: &str) -> Arc<tokio::sync::Mutex<()>> {
|
||||
let mut jobs = self.waveform_jobs.lock().unwrap();
|
||||
if jobs.len() >= WAVEFORM_JOB_MAX_IDLE_ENTRIES {
|
||||
// An entry with only the map's Arc is idle and safe to forget.
|
||||
// Active/waiting jobs may temporarily exceed the soft bound, but
|
||||
// they must keep their shared lock until their decoder exits.
|
||||
let idle: Vec<String> = jobs
|
||||
.iter()
|
||||
.filter(|(_, job)| Arc::strong_count(job) == 1)
|
||||
.map(|(key, _)| key.clone())
|
||||
.collect();
|
||||
for idle_key in idle {
|
||||
if jobs.len() < WAVEFORM_JOB_MAX_IDLE_ENTRIES {
|
||||
break;
|
||||
}
|
||||
jobs.remove(&idle_key);
|
||||
}
|
||||
}
|
||||
jobs.entry(key.to_string())
|
||||
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn release_waveform_job(&self, key: &str, job: &Arc<tokio::sync::Mutex<()>>) {
|
||||
let mut jobs = self.waveform_jobs.lock().unwrap();
|
||||
if jobs
|
||||
.get(key)
|
||||
.is_some_and(|current| Arc::ptr_eq(current, job) && Arc::strong_count(current) == 2)
|
||||
{
|
||||
jobs.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable cache filename for a remote file: short hash + original name.
|
||||
pub fn cache_path(app: &tauri::AppHandle, username: &str, remote_path: &str) -> Option<PathBuf> {
|
||||
let dir = app.path().app_data_dir().ok()?.join("stream-cache");
|
||||
std::fs::create_dir_all(&dir).ok()?;
|
||||
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for b in username.bytes().chain(remote_path.bytes()) {
|
||||
hash ^= b as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
let name = remote_path.rsplit('\\').next().unwrap_or(remote_path);
|
||||
let safe: String = name
|
||||
.chars()
|
||||
.map(|c| match c {
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||
c if (c as u32) < 0x20 => '_',
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
Some(dir.join(format!("{hash:016x} {safe}")))
|
||||
}
|
||||
|
||||
/// The in-flight download target for a cache file: `<name>.<ext>.partial`.
|
||||
pub fn partial_path(cache: &std::path::Path) -> PathBuf {
|
||||
cache.with_extension(format!(
|
||||
"{}partial",
|
||||
cache
|
||||
.extension()
|
||||
.map(|e| format!("{}.", e.to_string_lossy()))
|
||||
.unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
/// Enforce the configured streamed-track retention on the cache: drop files
|
||||
/// not played within the retention window, then oldest-first beyond the size
|
||||
/// cap. The playing track's file is held open by the OS, so its delete fails
|
||||
/// and it is simply kept.
|
||||
pub fn prune_cache(app: &tauri::AppHandle) {
|
||||
// Completion/settings paths can request pruning concurrently. Serialize
|
||||
// their directory snapshots so a stale running total cannot over-delete.
|
||||
let _prune_guard = CACHE_PRUNE_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
let cfg = crate::config::load(app);
|
||||
let max_bytes = crate::config::stream_cache_max_mb(&cfg) * 1024 * 1024; // 0 = unlimited
|
||||
let retention = std::time::Duration::from_secs(
|
||||
crate::config::stream_retention_days(&cfg) as u64 * 24 * 60 * 60,
|
||||
);
|
||||
// In keep-forever mode streams land in the library, so the cache is only
|
||||
// a transfer buffer — age expiry off, size cap still applies.
|
||||
let age_prune = !crate::config::stream_keep_forever(&cfg);
|
||||
|
||||
let Ok(dir) = app.path().app_data_dir().map(|d| d.join("stream-cache")) else {
|
||||
return;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = entries
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.to_string_lossy().eq_ignore_ascii_case("partial"))
|
||||
{
|
||||
// Active downloads are transfer buffers, not evictable cache
|
||||
// entries. Deleting one can make its final rename fail.
|
||||
return None;
|
||||
}
|
||||
let md = e.metadata().ok()?;
|
||||
if !md.is_file() {
|
||||
return None;
|
||||
}
|
||||
Some((md.modified().ok()?, md.len(), path))
|
||||
})
|
||||
.collect();
|
||||
files.sort_by_key(|(t, ..)| *t);
|
||||
|
||||
let now = std::time::SystemTime::now();
|
||||
let mut total: u64 = files.iter().map(|(_, len, _)| len).sum();
|
||||
let mut kept = Vec::new();
|
||||
for (modified, len, path) in files {
|
||||
let expired = age_prune
|
||||
&& now
|
||||
.duration_since(modified)
|
||||
.map(|age| age > retention)
|
||||
.unwrap_or(false);
|
||||
if expired && std::fs::remove_file(&path).is_ok() {
|
||||
total -= len;
|
||||
continue;
|
||||
}
|
||||
kept.push((len, path));
|
||||
}
|
||||
|
||||
if max_bytes == 0 {
|
||||
return;
|
||||
}
|
||||
for (len, path) in kept {
|
||||
if total <= max_bytes {
|
||||
break;
|
||||
}
|
||||
if std::fs::remove_file(&path).is_ok() {
|
||||
total -= len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a cache file as freshly used so retention counts from the last play,
|
||||
/// not the first stream.
|
||||
pub fn touch(path: &std::path::Path) {
|
||||
if let Ok(f) = std::fs::File::options().write(true).open(path) {
|
||||
let _ = f.set_modified(std::time::SystemTime::now());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn waveform_lru_evicts_oldest_and_refreshes_hits() {
|
||||
let mut cache = WaveformLru::new(2);
|
||||
cache.insert("a".into(), vec![1.0]);
|
||||
cache.insert("b".into(), vec![2.0]);
|
||||
assert_eq!(cache.get("a"), Some(vec![1.0]));
|
||||
|
||||
cache.insert("c".into(), vec![3.0]);
|
||||
assert_eq!(cache.get("b"), None);
|
||||
assert_eq!(cache.get("a"), Some(vec![1.0]));
|
||||
assert_eq!(cache.get("c"), Some(vec![3.0]));
|
||||
assert_eq!(cache.entries.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_waveform_waits_for_doubling_but_restarts_after_shrink() {
|
||||
let waveform = ProgressiveWaveform::new(10, 100, vec![1.0]);
|
||||
assert!(!waveform.should_recompute(10));
|
||||
assert!(!waveform.should_recompute(19));
|
||||
assert!(waveform.should_recompute(20));
|
||||
assert!(waveform.should_recompute(9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progressive_thresholds_bound_lag_and_total_decode_cost() {
|
||||
let total: u64 = 256 * 1024 * 1024;
|
||||
let mut decoded: u64 = 1024 * 1024;
|
||||
let mut cumulative = 0u64;
|
||||
let mut steps = 0;
|
||||
while decoded < total {
|
||||
cumulative += decoded;
|
||||
let next = next_progressive_waveform_threshold(decoded, total);
|
||||
assert!(next > decoded, "thresholds must advance");
|
||||
// The drawn wave may never fall more than an eighth of the file
|
||||
// behind the transfer — the doubling-only schedule's half-file
|
||||
// final gap is the bug this replaced (playhead walking off the
|
||||
// stale wave's end mid-song).
|
||||
assert!(
|
||||
next - decoded <= total / 8,
|
||||
"gap {} exceeds total/8",
|
||||
next - decoded
|
||||
);
|
||||
decoded = next;
|
||||
steps += 1;
|
||||
}
|
||||
assert_eq!(decoded, total);
|
||||
// Early doubling + eighth-of-file steps: the whole progressive series
|
||||
// costs a small constant number of full-file decodes, spread across
|
||||
// the download.
|
||||
assert!(steps <= 16, "too many recomputes: {steps}");
|
||||
assert!(cumulative <= total * 4, "cumulative redecode too large");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user