Skip to content

Commit 27c7526

Browse files
committed
Fix UI scaling, audio fallback, and FFmpeg robust seeking
1 parent 6a2924f commit 27c7526

7 files changed

Lines changed: 871 additions & 615 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "rusttracker"
3-
version = "0.9.8"
3+
version = "0.9.9"
44
edition = "2024"
55
default-run = "rusttracker"
66
description = "High-Performance Vulkan Tracker Module Visualizer"
@@ -67,7 +67,22 @@ objc2 = "0.6.4"
6767
objc2-core-audio = "0.3.2"
6868

6969
[profile.release]
70-
opt-level = "z"
71-
lto = true
72-
codegen-units = 1
70+
opt-level = 3
71+
lto = "thin"
7372
strip = true
73+
74+
[[bin]]
75+
name = "alsa_test2"
76+
path = "src/bin/alsa_test2.rs"
77+
78+
[[bin]]
79+
name = "alsa_test3"
80+
path = "src/bin/alsa_test3.rs"
81+
82+
[[bin]]
83+
name = "alsa_ffmpeg_test"
84+
path = "src/bin/alsa_ffmpeg_test.rs"
85+
86+
[[bin]]
87+
name = "alsa_test4"
88+
path = "src/bin/alsa_test4.rs"

src/audio.rs

Lines changed: 708 additions & 568 deletions
Large diffs are not rendered by default.

src/engine.rs

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ pub enum EngineAction {
4949
OpenFile,
5050
LoadFiles(Vec<String>, bool),
5151
Seek(f32),
52+
ScrubPreview(f32, f64),
53+
ScrubEnd,
5254
SetForceStereo(bool),
5355
#[allow(dead_code)]
5456
SetPassthrough(bool),
@@ -58,7 +60,6 @@ pub enum EngineAction {
5860
VisPickerToggleEnabled(usize),
5961
VisPickerSetCursor(usize),
6062
VisPickerEnableAll,
61-
SeekWithScrub(f32, f64),
6263
VisPickerEnableNone,
6364
OpenUrlDialog,
6465
CloseUrlDialog,
@@ -192,8 +193,8 @@ struct MeshBuffers {
192193
index_count: u32,
193194
}
194195

195-
pub struct VulkanEngine<'a> {
196-
surface: wgpu::Surface<'a>,
196+
pub struct VulkanEngine {
197+
surface: Option<wgpu::Surface<'static>>,
197198
device: wgpu::Device,
198199
queue: wgpu::Queue,
199200
pub config: wgpu::SurfaceConfiguration,
@@ -386,7 +387,7 @@ pub(crate) fn generate_lamp_mesh() -> (Vec<Vertex>, Vec<u32>) {
386387
(vertices, indices)
387388
}
388389

389-
impl<'a> VulkanEngine<'a> {
390+
impl VulkanEngine {
390391
pub async fn new(window: Arc<Window>) -> Self {
391392
let size = window.inner_size();
392393

@@ -2008,7 +2009,7 @@ impl<'a> VulkanEngine<'a> {
20082009
// --- END 3D Engine Extensions Init ---
20092010

20102011
Self {
2011-
surface,
2012+
surface: Some(surface),
20122013
device,
20132014
queue,
20142015
config,
@@ -2104,7 +2105,9 @@ impl<'a> VulkanEngine<'a> {
21042105
self.size = new_size;
21052106
self.config.width = new_size.width;
21062107
self.config.height = new_size.height;
2107-
self.surface.configure(&self.device, &self.config);
2108+
if let Some(surface) = &self.surface {
2109+
surface.configure(&self.device, &self.config);
2110+
}
21082111

21092112
let depth_texture = self.device.create_texture(&wgpu::TextureDescriptor {
21102113
label: Some("Depth Texture"),
@@ -2120,6 +2123,7 @@ impl<'a> VulkanEngine<'a> {
21202123
}
21212124
}
21222125

2126+
21232127
pub fn clear_video_state(&mut self) {
21242128
self.video_state = None;
21252129
}
@@ -2171,7 +2175,7 @@ impl<'a> VulkanEngine<'a> {
21712175
display_order: [0; 16],
21722176
num_channels: state.channel_vus.len().min(32) as u32,
21732177
mode: state.visualizer_mode,
2174-
time: state.current_seconds as f32,
2178+
time: state.scrub_target_seconds.unwrap_or(state.current_seconds) as f32,
21752179
duration: state.duration_seconds as f32,
21762180
smooth_time: self.play_time as f32,
21772181
heatmap_row: self.heatmap_row,
@@ -2529,8 +2533,13 @@ impl<'a> VulkanEngine<'a> {
25292533
file_dialog: &mut egui_file_dialog::FileDialog,
25302534
gamepad_events: Vec<egui::Event>
25312535
) -> Result<(EngineAction, f32, f32, Option<f32>, Option<f32>, Option<f32>, f32, f32, f32, f32), wgpu::SurfaceStatus> {
2536+
let physical_size = window.inner_size();
2537+
if physical_size.width > 0 && physical_size.height > 0 && physical_size != self.size {
2538+
self.resize(physical_size);
2539+
}
25322540
let surface_start = std::time::Instant::now();
2533-
let output = self.surface.get_current_texture();
2541+
let surface = self.surface.as_ref().ok_or(wgpu::SurfaceStatus::Lost)?;
2542+
let output = surface.get_current_texture();
25342543
let surface_texture = match output {
25352544
wgpu::CurrentSurfaceTexture::Success(tex) | wgpu::CurrentSurfaceTexture::Suboptimal(tex) => tex,
25362545
wgpu::CurrentSurfaceTexture::Lost => return Err(wgpu::SurfaceStatus::Lost),
@@ -3580,12 +3589,20 @@ impl<'a> VulkanEngine<'a> {
35803589
let (rect, response) = columns[0].allocate_exact_size(egui::vec2(columns[0].available_width(), 16.0), egui::Sense::click_and_drag());
35813590
out_fire_rect = Some(rect);
35823591

3583-
if response.dragged() || response.clicked() {
3592+
if response.drag_stopped() || response.clicked() {
35843593
if let Some(mouse_pos) = response.interact_pointer_pos() {
35853594
let rel_x = (mouse_pos.x - rect.left()).clamp(0.0, rect.width());
35863595
let pct = rel_x / rect.width();
35873596
engine_action = EngineAction::Seek(pct);
35883597
}
3598+
} else if response.dragged() {
3599+
if let Some(mouse_pos) = response.interact_pointer_pos() {
3600+
let rel_x = (mouse_pos.x - rect.left()).clamp(0.0, rect.width());
3601+
let pct = rel_x / rect.width();
3602+
engine_action = EngineAction::ScrubPreview(pct, 0.0);
3603+
}
3604+
} else if !response.is_pointer_button_down_on() {
3605+
engine_action = EngineAction::ScrubEnd;
35893606
}
35903607

35913608
let painter = columns[0].painter();
@@ -3596,10 +3613,11 @@ impl<'a> VulkanEngine<'a> {
35963613
format!("{:02}:{:02}.{}", m, s, f)
35973614
};
35983615

3616+
let display_secs = state.scrub_target_seconds.unwrap_or(state.current_seconds);
35993617
let time_text = if state.duration_seconds <= 0.0 {
3600-
format!("{} / LIVE", format_time(state.current_seconds))
3618+
format!("{} / LIVE", format_time(display_secs))
36013619
} else {
3602-
format!("{} / {}", format_time(state.current_seconds), format_time(state.duration_seconds))
3620+
format!("{} / {}", format_time(display_secs), format_time(state.duration_seconds))
36033621
};
36043622

36053623
painter.text(
@@ -3983,14 +4001,29 @@ impl<'a> VulkanEngine<'a> {
39834001
central_rect = rect;
39844002

39854003
// Mouse drag scrubbing using global input to avoid interception by other transparent areas
3986-
#[allow(deprecated)]
3987-
let wants_pointer = ui.ctx().wants_pointer_input();
3988-
if ui.input(|i| i.pointer.primary_down()) && !wants_pointer && state.duration_seconds > 0.0 {
3989-
let delta_x = ui.input(|i| i.pointer.delta().x);
3990-
if delta_x.abs() > 0.0 {
3991-
let scrub_amount = delta_x * 0.1; // 0.1s per pixel
3992-
let new_time = (state.current_seconds + scrub_amount as f64).clamp(0.0, state.duration_seconds);
3993-
engine_action = EngineAction::SeekWithScrub((new_time / state.duration_seconds) as f32, scrub_amount as f64);
4004+
let wants_pointer = ui.ctx().egui_wants_pointer_input();
4005+
let is_primary_down = ui.input(|i| i.pointer.primary_down());
4006+
let is_primary_released = ui.input(|i| i.pointer.primary_released());
4007+
4008+
// If we are currently scrubbing, ignore wants_pointer so we don't miss the release event!
4009+
let is_scrubbing = state.scrub_target_seconds.is_some();
4010+
let should_process = (is_primary_down || is_primary_released) && (!wants_pointer || is_scrubbing) && state.duration_seconds > 0.0;
4011+
4012+
if should_process {
4013+
if is_primary_released {
4014+
if let Some(target) = state.scrub_target_seconds {
4015+
engine_action = EngineAction::Seek((target / state.duration_seconds) as f32);
4016+
} else {
4017+
engine_action = EngineAction::ScrubEnd;
4018+
}
4019+
} else {
4020+
let delta_x = ui.input(|i| i.pointer.delta().x);
4021+
if delta_x.abs() > 0.0 || is_scrubbing {
4022+
let scrub_amount = delta_x * 0.1; // 0.1s per pixel
4023+
let base_time = state.scrub_target_seconds.unwrap_or(state.current_seconds);
4024+
let new_time = (base_time + scrub_amount as f64).clamp(0.0, state.duration_seconds);
4025+
engine_action = EngineAction::ScrubPreview((new_time / state.duration_seconds) as f32, scrub_amount as f64);
4026+
}
39944027
}
39954028
}
39964029

@@ -4040,7 +4073,7 @@ impl<'a> VulkanEngine<'a> {
40404073

40414074
});
40424075

4043-
let scale = window.scale_factor() as f32;
4076+
let scale = egui_ctx.pixels_per_point();
40444077
let w = self.config.width as f32;
40454078
let h = self.config.height as f32;
40464079

@@ -4074,7 +4107,7 @@ impl<'a> VulkanEngine<'a> {
40744107

40754108
let screen_descriptor = egui_wgpu::ScreenDescriptor {
40764109
size_in_pixels: [self.config.width, self.config.height],
4077-
pixels_per_point: window.scale_factor() as f32,
4110+
pixels_per_point: egui_ctx.pixels_per_point(),
40784111
};
40794112
let render_start = std::time::Instant::now();
40804113

@@ -4270,7 +4303,7 @@ impl<'a> VulkanEngine<'a> {
42704303
multiview_mask: None,
42714304
});
42724305

4273-
let scale_factor = window.scale_factor() as f32;
4306+
let scale_factor = egui_ctx.pixels_per_point();
42744307
let vp_x = ((central_rect.min.x * scale_factor).clamp(0.0, self.config.width as f32)).round();
42754308
let vp_y = ((central_rect.min.y * scale_factor).clamp(0.0, self.config.height as f32)).round();
42764309
let max_w = (self.config.width as f32 - vp_x).max(1.0);
@@ -4401,10 +4434,20 @@ impl<'a> VulkanEngine<'a> {
44014434
if let Some(rect) = r {
44024435
let x = ((rect.min.x * scale_factor).clamp(0.0, self.config.width as f32)).round() as u32;
44034436
let y = ((rect.min.y * scale_factor).clamp(0.0, self.config.height as f32)).round() as u32;
4404-
let max_w = (self.config.width as f32 - x as f32).max(1.0);
4405-
let w = ((rect.width() * scale_factor).clamp(1.0, max_w)).round() as u32;
4406-
let max_h = (self.config.height as f32 - y as f32).max(1.0);
4407-
let h = ((rect.height() * scale_factor).clamp(1.0, max_h)).round() as u32;
4437+
4438+
let w = if x < self.config.width {
4439+
let max_w = self.config.width - x;
4440+
((rect.width() * scale_factor).round() as u32).clamp(1, max_w)
4441+
} else {
4442+
0
4443+
};
4444+
4445+
let h = if y < self.config.height {
4446+
let max_h = self.config.height - y;
4447+
((rect.height() * scale_factor).round() as u32).clamp(1, max_h)
4448+
} else {
4449+
0
4450+
};
44084451

44094452
if w > 0 && h > 0 {
44104453
render_pass.set_scissor_rect(x, y, w, h);

src/main.rs

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,7 @@ async fn run_gui(app_state: Arc<Mutex<AppState>>, mut active_stream: Option<audi
265265
fonts.families.get_mut(&egui::FontFamily::Proportional).unwrap().push("kenney_icons".to_owned());
266266
egui_ctx.set_fonts(fonts);
267267

268-
let mut egui_state = egui_winit::State::new(egui_ctx.clone(), egui::ViewportId::ROOT, &window, None, None, None);
268+
let mut egui_state = egui_winit::State::new(egui_ctx.clone(), egui::ViewportId::ROOT, &window, Some(1.0), None, None);
269269

270270
let mut last_mouse_move = Instant::now();
271271
let mut is_cursor_visible = true;
@@ -573,6 +573,9 @@ async fn run_gui(app_state: Arc<Mutex<AppState>>, mut active_stream: Option<audi
573573
WindowEvent::CloseRequested => {
574574
elwt.exit();
575575
}
576+
WindowEvent::ScaleFactorChanged { .. } => {
577+
engine.resize(window.inner_size());
578+
}
576579
WindowEvent::Resized(physical_size) => {
577580
engine.resize(*physical_size);
578581
}
@@ -937,27 +940,30 @@ async fn run_gui(app_state: Arc<Mutex<AppState>>, mut active_stream: Option<audi
937940

938941
// Process engine actions
939942
match action {
940-
EngineAction::Seek(pct) => {
943+
EngineAction::ScrubPreview(pct, delta) => {
941944
let target = (state.duration_seconds * pct as f64).clamp(0.0, state.duration_seconds);
942-
state.seek_request = Some(target);
943-
state.spectrum_history.clear();
944-
for _ in 0..120 { state.spectrum_history.push_back(vec![0.0; 1024]); }
945+
state.scrub_target_seconds = Some(target);
946+
947+
if delta != 0.0 {
948+
if state.osd_timer > 0.0 && state.osd_text.as_ref().map_or(false, |s| s.starts_with("Scrubbing")) {
949+
state.cumulative_scrub += delta;
950+
} else {
951+
state.cumulative_scrub = delta;
952+
}
953+
state.osd_timer = 0.5;
954+
let sign = if state.cumulative_scrub > 0.0 { "+" } else { "-" };
955+
state.osd_text = Some(format!("Scrubbing {}{:.1}s", sign, state.cumulative_scrub.abs()));
956+
}
957+
}
958+
EngineAction::ScrubEnd => {
959+
state.scrub_target_seconds = None;
945960
}
946-
EngineAction::SeekWithScrub(pct, delta) => {
961+
EngineAction::Seek(pct) => {
947962
let target = (state.duration_seconds * pct as f64).clamp(0.0, state.duration_seconds);
948963
state.seek_request = Some(target);
964+
state.scrub_target_seconds = None;
949965
state.spectrum_history.clear();
950966
for _ in 0..120 { state.spectrum_history.push_back(vec![0.0; 1024]); }
951-
952-
if state.osd_timer > 0.0 && state.osd_text.as_ref().map_or(false, |s| s.starts_with("Scrubbing")) {
953-
state.cumulative_scrub += delta;
954-
} else {
955-
state.cumulative_scrub = delta;
956-
}
957-
958-
let sign = if state.cumulative_scrub > 0.0 { "+" } else { "-" };
959-
state.osd_text = Some(format!("Scrubbing {}{:.1}s", sign, state.cumulative_scrub.abs()));
960-
state.osd_timer = 0.5;
961967
}
962968
EngineAction::OpenFile => {
963969
if is_game_mode {

src/state.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ pub struct AppState {
128128
pub bitrate: Option<u32>,
129129
pub current_seconds: f64,
130130
pub seek_request: Option<f64>,
131+
pub scrub_target_seconds: Option<f64>,
131132
pub seek_epoch: u64,
132133
pub is_paused: bool,
133134
pub bpm: i32,
@@ -282,6 +283,7 @@ impl AppState {
282283
bitrate: None,
283284
current_seconds: 0.0,
284285
seek_request: None,
286+
scrub_target_seconds: None,
285287
seek_epoch: 0,
286288
is_paused: true, // Start paused to prevent audio playback before UI is ready
287289
bpm: 0,
@@ -369,6 +371,7 @@ impl AppState {
369371
bitrate: self.bitrate,
370372
current_seconds: self.current_seconds,
371373
seek_request: None,
374+
scrub_target_seconds: self.scrub_target_seconds,
372375
seek_epoch: self.seek_epoch,
373376
is_paused: self.is_paused,
374377
visual_width: self.visual_width,

tests/test_audio_rs.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,52 @@ fn test_midi_loading() {
1212
let mut source = result.unwrap();
1313
println!("Parsed MIDI duration: {}s", source.get_duration_seconds());
1414
}
15+
16+
#[test]
17+
fn test_audio_transition_and_fallback() {
18+
let shared_state = std::sync::Arc::new(std::sync::Mutex::new(state::AppState::new("Test App".to_string())));
19+
20+
println!("Starting first audio track (MOD)...");
21+
let handle1 = audio::start_audio_thread("audio_tests/ive_got_the_power.mod", false, shared_state.clone());
22+
assert!(handle1.is_ok(), "Failed to start first audio thread: {:?}", handle1.err());
23+
let handle1 = handle1.unwrap();
24+
25+
std::thread::sleep(std::time::Duration::from_secs(1));
26+
27+
println!("Stopping first audio track (dropping handle)...");
28+
drop(handle1);
29+
30+
std::thread::sleep(std::time::Duration::from_millis(200));
31+
32+
println!("Starting second audio track (MIDI transition)...");
33+
let handle2 = audio::start_audio_thread("audio_tests/darude-sandstorm.mid", false, shared_state.clone());
34+
assert!(handle2.is_ok(), "Failed to start second audio track: {:?}", handle2.err());
35+
let handle2 = handle2.unwrap();
36+
37+
std::thread::sleep(std::time::Duration::from_secs(1));
38+
39+
println!("Stopping second audio track (dropping handle)...");
40+
drop(handle2);
41+
42+
println!("Audio transition test completed successfully!");
43+
}
44+
45+
#[test]
46+
fn test_video_transition_and_fallback() {
47+
let shared_state = std::sync::Arc::new(std::sync::Mutex::new(state::AppState::new("Test App".to_string())));
48+
let movie_path = "/home/naoki/Music/Jeff Wayne's Musical Version Of The War Of The Worlds The New Generation (2013) [1080p] [BluRay] [5.1] [YTS.MX]/Jeff.Wayne's.Musical.Version.Of.The.War.Of.The.Worlds.The.New.Generation.2013.1080p.BluRay.x264.AAC5.1-[YTS.MX].mp4";
49+
50+
if std::path::Path::new(movie_path).exists() {
51+
println!("Starting video audio track...");
52+
let handle = audio::start_audio_thread(movie_path, false, shared_state.clone());
53+
assert!(handle.is_ok(), "Failed to start video audio thread: {:?}", handle.err());
54+
let handle = handle.unwrap();
55+
56+
std::thread::sleep(std::time::Duration::from_secs(1));
57+
58+
println!("Stopping video audio track...");
59+
drop(handle);
60+
} else {
61+
println!("Movie file not found, skipping video transition test.");
62+
}
63+
}

0 commit comments

Comments
 (0)