diff --git a/README.md b/README.md
index 3360e35..8312a60 100644
--- a/README.md
+++ b/README.md
@@ -103,6 +103,37 @@ RUSTFLAGS="-L$(brew --prefix sdl3)/lib" cargo run
cargo run --release
```
+### Configuration file
+
+You can pass a config file with `-c`:
+
+```
+./projectm -c config.toml
+./projectm -c config.properties
+```
+
+Supported formats: `.toml`, `.json`, `.yaml`, `.properties`
+
+The `.properties` format is compatible with config files from the C++ SDL frontend (projectMSDL). Example:
+
+```
+audio.device: BlackHole 2ch
+projectM.presetPath: /path/to/your/presets
+projectM.shuffleEnabled: true
+projectM.transitionDuration: 10
+projectM.displayDuration: 60
+window.width: 1280
+window.height: 720
+```
+
+### macOS: Microphone / audio capture permission
+
+On macOS, the application needs permission to capture audio. If the visualizer doesn't react to audio, check:
+
+**System Settings → Privacy & Security → Microphone**
+
+Grant access to your terminal application (e.g. Terminal.app, iTerm2) or the projectm binary directly. macOS silently blocks audio capture without this permission — no error will be shown.
+
(back to top)
diff --git a/src/app.rs b/src/app.rs
index 72555b2..477088c 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -44,9 +44,13 @@ impl App {
assert_eq!(gl_attr.context_profile(), GLProfile::Core);
assert_eq!(gl_attr.context_version(), (3, 3));
+ // Determine initial window size from config or use defaults
+ let initial_width = config.window_width.unwrap_or(1024);
+ let initial_height = config.window_height.unwrap_or(768);
+
// create window
let mut window = video_subsystem
- .window("ProjectM", 1024, 768)
+ .window("ProjectM", initial_width, initial_height)
.opengl()
.build()
.expect("could not initialize video subsystem");
@@ -61,19 +65,27 @@ impl App {
// and a preset playlist
let playlist = projectm::playlist::Playlist::create(&pm);
- // make window full-size
- let primary_display = video_subsystem.get_primary_display().unwrap();
- let display_bounds = primary_display.get_usable_bounds().unwrap();
- window
- .set_size(display_bounds.width(), display_bounds.height())
- .unwrap();
- window.set_position(WindowPos::Centered, WindowPos::Centered);
+ // Apply window position if override is requested
+ if config.window_override_position.unwrap_or(false) {
+ let x = config.window_left.unwrap_or(0);
+ let y = config.window_top.unwrap_or(0);
+ window.set_position(WindowPos::Positioned(x), WindowPos::Positioned(y));
+ } else if config.window_width.is_none() {
+ // Only go full-size if no explicit window size was given
+ let primary_display = video_subsystem.get_primary_display().unwrap();
+ let display_bounds = primary_display.get_usable_bounds().unwrap();
+ window
+ .set_size(display_bounds.width(), display_bounds.height())
+ .unwrap();
+ window.set_position(WindowPos::Centered, WindowPos::Centered);
+ }
+
window
.set_display_mode(None)
.expect("could not set display mode");
- // initialize audio
- let audio = audio::Audio::new(&sdl_context, Rc::clone(&pm));
+ // initialize audio, passing optional device name
+ let audio = audio::Audio::new(&sdl_context, Rc::clone(&pm), config.audio_device.clone());
println!("Application initialized with configuration:\n{}", config);
@@ -90,7 +102,8 @@ impl App {
pub fn init(&mut self) {
// load config
- self.apply_config(&self.config);
+ let config = self.config.clone();
+ self.apply_config(&config);
// initialize audio
self.audio.init(self.get_frame_rate());
diff --git a/src/app/audio.rs b/src/app/audio.rs
index 0dfc506..2951787 100644
--- a/src/app/audio.rs
+++ b/src/app/audio.rs
@@ -15,10 +15,11 @@ pub struct Audio {
projectm: ProjectMWrapped,
current_device_id: Option,
current_device_name: Option, // Store device name for comparison
+ requested_device_name: Option, // Device name from config
}
impl Audio {
- pub fn new(sdl_context: &sdl3::Sdl, projectm: ProjectMWrapped) -> Self {
+ pub fn new(sdl_context: &sdl3::Sdl, projectm: ProjectMWrapped, requested_device_name: Option) -> Self {
let audio_subsystem = sdl_context.audio().unwrap();
println!(
"Using audio driver: {}",
@@ -33,6 +34,7 @@ impl Audio {
current_device_name: None,
recording_stream: None,
projectm,
+ requested_device_name,
}
}
@@ -42,7 +44,25 @@ impl Audio {
self.frame_rate = Some(frame_rate);
#[cfg(not(feature = "dummy_audio"))]
- self.begin_audio_recording(None);
+ {
+ // If a device name was requested, find it and use it
+ if let Some(ref name) = self.requested_device_name {
+ if let Some(device_id) = self.find_device_by_name(name) {
+ println!("Found requested audio device: {}", name);
+ self.begin_audio_recording(Some(device_id));
+ } else {
+ println!(
+ "Warning: Requested audio device '{}' not found. Available devices:",
+ name
+ );
+ self.list_devices();
+ println!("Falling back to default device.");
+ self.begin_audio_recording(None);
+ }
+ } else {
+ self.begin_audio_recording(None);
+ }
+ }
}
pub fn list_devices(&self) {
@@ -58,6 +78,32 @@ impl Audio {
}
}
+ /// Find an audio device by name (case-insensitive substring match).
+ fn find_device_by_name(&self, name: &str) -> Option {
+ let devices = self.get_device_list();
+ let name_lower = name.to_lowercase();
+
+ // Try exact match first
+ for device in &devices {
+ if let Ok(device_name) = device.name() {
+ if device_name.to_lowercase() == name_lower {
+ return Some(*device);
+ }
+ }
+ }
+
+ // Fall back to substring match
+ for device in &devices {
+ if let Ok(device_name) = device.name() {
+ if device_name.to_lowercase().contains(&name_lower) {
+ return Some(*device);
+ }
+ }
+ }
+
+ None
+ }
+
/// Start capturing audio from device_id.
pub fn begin_audio_recording(&mut self, device_id: Option) {
// Stop capturing from current stream/device
diff --git a/src/app/config.rs b/src/app/config.rs
index 1b84b28..ab4f2c0 100644
--- a/src/app/config.rs
+++ b/src/app/config.rs
@@ -8,6 +8,7 @@ const RESOURCE_DIR_DEFAULT: &str = "/usr/local/share/projectM";
/// Configuration for the application
/// Parameters are defined here: https://github.com/projectM-visualizer/projectm/blob/master/src/api/include/projectM-4/parameters.h
+#[derive(Clone)]
pub struct Config {
/// Frame rate to render at. Defaults to 60.
pub frame_rate: Option,
@@ -23,6 +24,37 @@ pub struct Config {
/// How long to play a preset before switching to a new one (seconds).
pub preset_duration: Option,
+
+ /// Whether to shuffle presets.
+ pub shuffle_enabled: Option,
+
+ /// Soft-cut (crossfade blend) duration between presets (seconds).
+ /// Maps to projectM.transitionDuration in .properties files.
+ pub soft_cut_duration: Option,
+
+ /// Whether the current preset is locked (won't auto-switch).
+ pub preset_locked: Option,
+
+ /// Audio device name to use for capture.
+ pub audio_device: Option,
+
+ /// Window width.
+ pub window_width: Option,
+
+ /// Window height.
+ pub window_height: Option,
+
+ /// Window X position.
+ pub window_left: Option,
+
+ /// Window Y position.
+ pub window_top: Option,
+
+ /// Monitor index.
+ pub window_monitor: Option,
+
+ /// Whether to override default window position.
+ pub window_override_position: Option,
}
impl fmt::Display for Config {
@@ -57,11 +89,30 @@ impl fmt::Display for Config {
self.beat_sensitivity
.map_or("Not specified".to_string(), |s| s.to_string())
)?;
- write!(
+ writeln!(
f,
" Preset Duration: {}",
self.preset_duration
.map_or("Not specified".to_string(), |d| d.to_string())
+ )?;
+ writeln!(
+ f,
+ " Shuffle: {}",
+ self.shuffle_enabled
+ .map_or("Not specified".to_string(), |s| s.to_string())
+ )?;
+ writeln!(
+ f,
+ " Soft-cut Duration: {}",
+ self.soft_cut_duration
+ .map_or("Not specified".to_string(), |d| d.to_string())
+ )?;
+ write!(
+ f,
+ " Audio Device: {}",
+ self.audio_device
+ .as_ref()
+ .map_or("default".to_string(), |d| d.clone())
)
}
}
@@ -103,12 +154,22 @@ impl Default for Config {
frame_rate: Some(60),
beat_sensitivity: Some(1.0),
preset_duration: Some(10.0),
+ shuffle_enabled: None,
+ soft_cut_duration: None,
+ preset_locked: None,
+ audio_device: None,
+ window_width: None,
+ window_height: None,
+ window_left: None,
+ window_top: None,
+ window_monitor: None,
+ window_override_position: None,
}
}
}
impl App {
- pub fn apply_config(&self, config: &Config) {
+ pub fn apply_config(&mut self, config: &Config) {
let pm = &self.pm;
// set frame rate if provided
@@ -116,9 +177,33 @@ impl App {
pm.set_fps(frame_rate);
}
- // load presets if provided
+ // set beat sensitivity if provided
+ if let Some(beat_sensitivity) = config.beat_sensitivity {
+ pm.set_beat_sensitivity(beat_sensitivity);
+ }
+
+ // set preset duration if provided (must be set before play_next so the
+ // correct duration is in effect when the first preset starts its timer)
+ if let Some(preset_duration) = config.preset_duration {
+ pm.set_preset_duration(preset_duration);
+ }
+
+ // set soft-cut (crossfade) duration if provided
+ // maps to projectM.transitionDuration in .properties files
+ if let Some(soft_cut_duration) = config.soft_cut_duration {
+ pm.set_soft_cut_duration(soft_cut_duration);
+ }
+
+ // set shuffle mode
+ if let Some(shuffle) = config.shuffle_enabled {
+ self.playlist.set_shuffle(shuffle);
+ }
+
+ // load presets and start playback (after duration is configured)
if let Some(preset_path) = &config.preset_path {
self.add_preset_path(preset_path);
+ // Trigger playback of the first preset from the loaded path
+ self.playlist.play_next();
}
// load textures if provided
@@ -127,18 +212,9 @@ impl App {
pm.set_texture_search_paths(&paths, 1);
}
- // set beat sensitivity if provided
- if let Some(beat_sensitivity) = config.beat_sensitivity {
- pm.set_beat_sensitivity(beat_sensitivity);
- }
-
- // set preset duration if provided
- if let Some(preset_duration) = config.preset_duration {
- pm.set_preset_duration(preset_duration);
- }
-
- // set preset shuffle mode
- // self.playlist.set_shuffle(true);
+ // Note: preset_locked from .properties is acknowledged but the projectM
+ // playlist API doesn't expose a direct lock method in the Rust bindings.
+ // Users can press the preset lock key at runtime instead.
}
pub fn get_frame_rate(&self) -> FrameRate {
diff --git a/src/main.rs b/src/main.rs
index 4b94c8a..9b12290 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,5 +1,6 @@
mod app;
mod dummy_audio;
+mod properties;
use std::path::PathBuf;
use crate::app::config::Config;
@@ -16,7 +17,7 @@ use confique::Config as ConfiqueConfig;
/// Need help? Join discord: https://discord.gg/uSSggaMBrv
struct Settings {
#[arg(short, long = "config")]
- /// Path to a config file
+ /// Path to a config file (supports .toml, .json, .yaml, .properties)
config_path: Option,
#[arg(short, long)]
@@ -36,21 +37,29 @@ struct Settings {
texture_path: Option,
#[arg(short, long)]
- #[arg(default_value = "1.0")]
#[arg(env = "PM_BEAT_SENSITIVITY")]
/// Sensitivity of the beat detection
beat_sensitivity: Option,
#[arg(short = 'd', long)]
- #[arg(default_value = "10")]
#[arg(env = "PM_PRESET_DURATION")]
/// Duration (seconds) each preset will play
preset_duration: Option,
- // TODO: Add option for specifying audio device
- // #[arg(short, long)]
- // #[arg(env = "PM_AUDIO_INPUT")]
- // /// Audio input device (name or index)
- // audio_input: Option,
+
+ #[arg(short, long)]
+ #[arg(env = "PM_AUDIO_INPUT")]
+ /// Audio input device (name or substring match)
+ audio_device: Option,
+
+ #[arg(long)]
+ #[arg(env = "PM_SHUFFLE")]
+ /// Enable preset shuffling
+ shuffle_enabled: Option,
+
+ #[arg(long)]
+ #[arg(env = "PM_TRANSITION_DURATION")]
+ /// Duration (seconds) of transitions between presets
+ transition_duration: Option,
}
impl Default for Settings {
@@ -62,6 +71,9 @@ impl Default for Settings {
texture_path: None,
beat_sensitivity: None,
preset_duration: None,
+ audio_device: None,
+ shuffle_enabled: None,
+ transition_duration: None,
}
}
}
@@ -87,6 +99,15 @@ impl Settings {
if let Some(preset_duration) = other.preset_duration {
self.preset_duration = Some(preset_duration);
}
+ if let Some(audio_device) = &other.audio_device {
+ self.audio_device = Some(audio_device.clone());
+ }
+ if let Some(shuffle_enabled) = other.shuffle_enabled {
+ self.shuffle_enabled = Some(shuffle_enabled);
+ }
+ if let Some(transition_duration) = other.transition_duration {
+ self.transition_duration = Some(transition_duration);
+ }
}
}
@@ -97,12 +118,20 @@ fn load_settings_file(path: Option) -> Result {
if !path.exists() {
return Err(format!("config path invalid: {}", path.display()));
}
- // ensure extention is valid
- match path.extension().and_then(|ext| ext.to_str()) {
- Some("toml") | Some("json") | Some("yaml)") => {}
+
+ let extension = path.extension().and_then(|ext| ext.to_str());
+
+ match extension {
+ // .properties format — use custom parser
+ Some("properties") => {
+ println!("Loading .properties config from: {}", path.display());
+ return load_properties_settings(&path);
+ }
+ // TOML/JSON/YAML — use confique
+ Some("toml") | Some("json") | Some("yaml") => {}
_ => {
return Err(format!(
- "invalid config file extension: {:?}",
+ "invalid config file extension: {:?}. Supported: .toml, .json, .yaml, .properties",
path.extension()
))
}
@@ -120,14 +149,27 @@ fn load_settings_file(path: Option) -> Result {
}
// No path, return empty settings
- return Ok(Settings {
- config_path: None,
- frame_rate: None,
- preset_path: None,
- texture_path: None,
- beat_sensitivity: None,
- preset_duration: None,
- });
+ return Ok(Settings::default());
+}
+
+/// Load settings from a projectMSDL .properties file.
+fn load_properties_settings(path: &std::path::Path) -> Result {
+ let props = properties::parse_properties_file(path)?;
+ let ps = properties::apply_properties(&props);
+
+ Ok(Settings {
+ config_path: Some(path.to_path_buf()),
+ frame_rate: None, // Not in .properties format
+ preset_path: ps.preset_path,
+ texture_path: None, // Not in .properties format
+ beat_sensitivity: None, // Not in .properties format
+ // displayDuration → preset_duration (how long a preset plays before switching)
+ preset_duration: ps.display_duration,
+ audio_device: ps.audio_device,
+ shuffle_enabled: ps.shuffle_enabled,
+ // transitionDuration → soft_cut_duration (crossfade blend time)
+ transition_duration: ps.transition_duration,
+ })
}
fn load_settings() -> Result {
@@ -146,12 +188,34 @@ fn load_settings() -> Result {
fn main() -> Result<(), String> {
let settings = load_settings()?;
+ // Build window config from properties if loaded
+ let props_window = if let Some(ref config_path) = settings.config_path {
+ if config_path.extension().and_then(|e| e.to_str()) == Some("properties") {
+ let props = properties::parse_properties_file(config_path).ok();
+ props.map(|p| properties::apply_properties(&p))
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
let app_config = Config {
frame_rate: settings.frame_rate,
preset_path: settings.preset_path,
texture_path: settings.texture_path,
beat_sensitivity: settings.beat_sensitivity,
preset_duration: settings.preset_duration,
+ shuffle_enabled: settings.shuffle_enabled,
+ soft_cut_duration: settings.transition_duration,
+ preset_locked: props_window.as_ref().and_then(|p| p.preset_locked),
+ audio_device: settings.audio_device,
+ window_width: props_window.as_ref().and_then(|p| p.window_width),
+ window_height: props_window.as_ref().and_then(|p| p.window_height),
+ window_left: props_window.as_ref().and_then(|p| p.window_left),
+ window_top: props_window.as_ref().and_then(|p| p.window_top),
+ window_monitor: props_window.as_ref().and_then(|p| p.window_monitor),
+ window_override_position: props_window.as_ref().and_then(|p| p.window_override_position),
};
// Initialize the application
@@ -220,4 +284,40 @@ mod tests {
std::env::remove_var("PM_PRESET_DURATION");
std::env::remove_var("PM_AUDIO_INPUT");
}
+
+ #[test]
+ fn test_load_properties() {
+ use std::fs;
+ use std::io::Write;
+
+ let dir = std::env::temp_dir();
+ let path = dir.join("test_load.properties");
+ let mut f = fs::File::create(&path).unwrap();
+ write!(
+ f,
+ r#"audio.device: BlackHole 2ch
+projectM.displayDuration: 60.549
+projectM.shuffleEnabled: true
+projectM.transitionDuration: 10
+projectM.presetPath: /Users/delta/presets
+window.width: 850
+window.height: 448
+"#
+ )
+ .unwrap();
+
+ let settings =
+ crate::load_settings_file(Some(path.clone())).expect("Properties should load");
+
+ assert_eq!(
+ settings.preset_path.as_ref().map(|p| p.to_str().unwrap()),
+ Some("/Users/delta/presets")
+ );
+ assert_eq!(settings.shuffle_enabled, Some(true));
+ assert_eq!(settings.transition_duration, Some(10.0));
+ assert_eq!(settings.preset_duration, Some(60.549));
+ assert_eq!(settings.audio_device.as_deref(), Some("BlackHole 2ch"));
+
+ fs::remove_file(&path).ok();
+ }
}
diff --git a/src/properties.rs b/src/properties.rs
new file mode 100644
index 0000000..fe6a787
--- /dev/null
+++ b/src/properties.rs
@@ -0,0 +1,239 @@
+//! Parser for projectMSDL .properties config files.
+//!
+//! The C++ SDL frontend uses a `key: value` format with dotted namespaces:
+//! - `projectM.*` keys for visualizer settings
+//! - `window.*` keys for window geometry
+//! - `audio.*` keys for audio device selection
+//!
+//! This module parses these in two passes (window first, then projectM/audio)
+//! and maps them into Settings.
+
+use std::collections::HashMap;
+use std::fs;
+use std::path::Path;
+
+/// Raw parsed properties from a .properties file, split by namespace.
+pub struct ParsedProperties {
+ pub window: HashMap,
+ pub projectm: HashMap,
+ pub audio: HashMap,
+}
+
+/// Parse a .properties file into namespaced groups.
+///
+/// Format: `key: value` per line. Lines starting with `#` or `!` are comments.
+/// Empty lines are ignored.
+pub fn parse_properties_file(path: &Path) -> Result {
+ let content = fs::read_to_string(path)
+ .map_err(|e| format!("Failed to read properties file: {}", e))?;
+
+ let mut window = HashMap::new();
+ let mut projectm = HashMap::new();
+ let mut audio = HashMap::new();
+
+ for line in content.lines() {
+ let line = line.trim();
+
+ // Skip empty lines and comments
+ if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
+ continue;
+ }
+
+ // Split on first `: ` or `=`
+ let (key, value) = if let Some(idx) = line.find(": ") {
+ (&line[..idx], line[idx + 2..].trim())
+ } else if let Some(idx) = line.find('=') {
+ (&line[..idx], line[idx + 1..].trim())
+ } else {
+ continue; // Malformed line, skip
+ };
+
+ let key = key.trim();
+
+ // Route to the appropriate namespace
+ if let Some(suffix) = key.strip_prefix("window.") {
+ window.insert(suffix.to_string(), value.to_string());
+ } else if let Some(suffix) = key.strip_prefix("projectM.") {
+ projectm.insert(suffix.to_string(), value.to_string());
+ } else if let Some(suffix) = key.strip_prefix("audio.") {
+ audio.insert(suffix.to_string(), value.to_string());
+ }
+ }
+
+ Ok(ParsedProperties {
+ window,
+ projectm,
+ audio,
+ })
+}
+
+/// Parse a boolean value from a properties file.
+/// Accepts "true"/"false" (case-insensitive).
+fn parse_bool(value: &str) -> Option {
+ match value.to_lowercase().as_str() {
+ "true" => Some(true),
+ "false" => Some(false),
+ _ => None,
+ }
+}
+
+/// Convert parsed properties into Settings fields.
+///
+/// This is done in two passes as requested:
+/// 1. Window pass: extract window geometry settings
+/// 2. ProjectM/Audio pass: extract visualizer and audio settings
+pub fn apply_properties(
+ props: &ParsedProperties,
+) -> PropertiesSettings {
+ let mut settings = PropertiesSettings::default();
+
+ // --- Pass 1: Window properties ---
+ if let Some(v) = props.window.get("width") {
+ settings.window_width = v.parse().ok();
+ }
+ if let Some(v) = props.window.get("height") {
+ settings.window_height = v.parse().ok();
+ }
+ if let Some(v) = props.window.get("left") {
+ settings.window_left = v.parse().ok();
+ }
+ if let Some(v) = props.window.get("top") {
+ settings.window_top = v.parse().ok();
+ }
+ if let Some(v) = props.window.get("monitor") {
+ settings.window_monitor = v.parse().ok();
+ }
+ if let Some(v) = props.window.get("overridePosition") {
+ settings.window_override_position = parse_bool(v);
+ }
+
+ // --- Pass 2: ProjectM and Audio properties ---
+ if let Some(v) = props.projectm.get("presetPath") {
+ // Normalize Windows-style backslashes to forward slashes
+ let normalized = v.replace("\\\\", "/").replace('\\', "/");
+ // Remove trailing slash if present
+ let normalized = normalized.trim_end_matches('/').to_string();
+ settings.preset_path = Some(normalized.into());
+ }
+ if let Some(v) = props.projectm.get("shuffleEnabled") {
+ settings.shuffle_enabled = parse_bool(v);
+ }
+ if let Some(v) = props.projectm.get("transitionDuration") {
+ settings.transition_duration = v.parse().ok();
+ }
+ if let Some(v) = props.projectm.get("displayDuration") {
+ settings.display_duration = v.parse().ok();
+ }
+ if let Some(v) = props.projectm.get("presetLocked") {
+ settings.preset_locked = parse_bool(v);
+ }
+ if let Some(v) = props.projectm.get("enableSplash") {
+ settings.enable_splash = parse_bool(v);
+ }
+
+ if let Some(v) = props.audio.get("device") {
+ settings.audio_device = Some(v.to_string());
+ }
+
+ settings
+}
+
+/// All settings that can be extracted from a .properties file.
+#[derive(Debug, Default)]
+pub struct PropertiesSettings {
+ // Window
+ pub window_width: Option,
+ pub window_height: Option,
+ pub window_left: Option,
+ pub window_top: Option,
+ pub window_monitor: Option,
+ pub window_override_position: Option,
+
+ // ProjectM
+ pub preset_path: Option,
+ pub shuffle_enabled: Option,
+ pub transition_duration: Option,
+ pub display_duration: Option,
+ pub preset_locked: Option,
+ pub enable_splash: Option,
+
+ // Audio
+ pub audio_device: Option,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::io::Write;
+
+ #[test]
+ fn test_parse_properties() {
+ let dir = std::env::temp_dir();
+ let path = dir.join("test_projectm.properties");
+ let mut f = fs::File::create(&path).unwrap();
+ write!(
+ f,
+ r#"audio.device: BlackHole 2ch
+projectM.displayDuration: 60.549
+projectM.enableSplash: false
+projectM.presetLocked: false
+projectM.presetPath: /Users/delta/presets
+projectM.shuffleEnabled: true
+projectM.transitionDuration: 10
+window.height: 448
+window.left: 666
+window.monitor: 2
+window.overridePosition: false
+window.top: 212
+window.width: 850
+"#
+ )
+ .unwrap();
+
+ let props = parse_properties_file(&path).unwrap();
+ let settings = apply_properties(&props);
+
+ assert_eq!(settings.window_width, Some(850));
+ assert_eq!(settings.window_height, Some(448));
+ assert_eq!(settings.window_left, Some(666));
+ assert_eq!(settings.window_top, Some(212));
+ assert_eq!(settings.window_monitor, Some(2));
+ assert_eq!(settings.window_override_position, Some(false));
+
+ assert_eq!(
+ settings.preset_path.as_ref().map(|p| p.to_str().unwrap()),
+ Some("/Users/delta/presets")
+ );
+ assert_eq!(settings.shuffle_enabled, Some(true));
+ assert_eq!(settings.transition_duration, Some(10.0));
+ assert_eq!(settings.display_duration, Some(60.549));
+ assert_eq!(settings.preset_locked, Some(false));
+ assert_eq!(settings.enable_splash, Some(false));
+
+ assert_eq!(settings.audio_device.as_deref(), Some("BlackHole 2ch"));
+
+ fs::remove_file(&path).ok();
+ }
+
+ #[test]
+ fn test_windows_path_normalization() {
+ let dir = std::env::temp_dir();
+ let path = dir.join("test_winpath.properties");
+ let mut f = fs::File::create(&path).unwrap();
+ write!(
+ f,
+ r#"projectM.presetPath: C:\\Users\\Dumbfuck\\presets\\"#
+ )
+ .unwrap();
+
+ let props = parse_properties_file(&path).unwrap();
+ let settings = apply_properties(&props);
+
+ assert_eq!(
+ settings.preset_path.as_ref().map(|p| p.to_str().unwrap()),
+ Some("C:/Users/Dumbfuck/presets")
+ );
+
+ fs::remove_file(&path).ok();
+ }
+}
diff --git a/test-data/config.properties b/test-data/config.properties
new file mode 100644
index 0000000..5ed0e74
--- /dev/null
+++ b/test-data/config.properties
@@ -0,0 +1,13 @@
+audio.device: BlackHole 2ch
+projectM.displayDuration: 60.54899978637695
+projectM.enableSplash: false
+projectM.presetLocked: false
+projectM.presetPath: /Users/delta/doritostash/deltaryz assets/deltamix stream/projectm presets
+projectM.shuffleEnabled: true
+projectM.transitionDuration: 10
+window.height: 448
+window.left: 666
+window.monitor: 2
+window.overridePosition: false
+window.top: 212
+window.width: 850