use bevy::{log::Level, prelude::*}; use std::{collections::HashMap, fs::File, io::BufReader, path::Path}; use serde::{Deserialize, Serialize}; use bevy_egui::{EguiContextSettings, EguiContexts, EguiPrimaryContextPass, EguiStartupSet, egui}; use egui::ecolor::Color32; #[cfg(target_os = "linux")] mod linux_lib; #[cfg(target_os = "windows")] mod windows_lib; use rodio::{ Decoder, OutputStream, Sink, Source, cpal::{self, traits::HostTrait}, OutputStreamBuilder }; #[derive(Serialize, Deserialize)] struct JSONData { tabs: Vec, } #[allow(dead_code)] struct PlayingSound { file_path: String, length: f32, sink: Sink, #[cfg(target_os = "windows")] normal_sink: Sink } struct SoundSystem { #[cfg(target_os = "windows")] normal_output_stream: OutputStream, output_stream: OutputStream, paused: bool, } #[derive(Resource)] struct AppState { loaded_files: HashMap>, json_data: JSONData, current_directory: String, currently_playing: Vec, sound_system: SoundSystem, virt_outputs: Vec<(String, String)>, virt_output_index_switch: String, virt_output_index: String, } const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"]; fn create_virtual_mic() -> SoundSystem { #[cfg(target_os = "windows")] { let (normal, virtual_mic) = windows_lib::create_virtual_mic_windows(); return SoundSystem { output_stream: virtual_mic, normal_output_stream: normal, paused: false, }; } #[cfg(target_os = "linux")] { return SoundSystem { output_stream: linux_lib::create_virtual_mic_linux(), paused: false, }; } #[allow(unreachable_code)] { let host = cpal::default_host(); let device = host.default_output_device().expect("Could not get default output device"); SoundSystem { output_stream: OutputStreamBuilder::from_device(device) .expect("Unable to open device") .open_stream() .expect("Failed to open stream"), // this is actually not needed here, since windows would exit by far. But, cargo doesnt like SoundSystem not getting the normal_output stream so... #[cfg(target_os = "windows")] normal_output_stream: OutputStreamBuilder::from_device(device) .expect("Unable to open device") .open_stream() .expect("Failed to open stream"), paused: false, } } } fn reload_sound() -> SoundSystem { #[cfg(target_os = "linux")] linux_lib::reload_sound(); return create_virtual_mic(); } fn list_outputs() -> Vec<(String, String)> { #[cfg(target_os = "windows")] return Vec::from([("Select in apps".to_string(), String::from("9999999"))]); #[cfg(target_os = "linux")] return linux_lib::list_outputs(); #[allow(unreachable_code)] return Vec::new(); } fn main() { App::new() .insert_resource(ClearColor(Color::BLACK)) .add_plugins( DefaultPlugins .set(bevy::log::LogPlugin { filter: "warn,ui=info".to_string(), level: Level::INFO, ..Default::default() }) .set(WindowPlugin { primary_window: Some(Window { // You may want this set to `true` if you need virtual keyboard work in mobile browsers. prevent_default_event_handling: false, ..default() }), ..default() }), ) .add_plugins(bevy_egui::EguiPlugin::default()) .insert_resource(AppState { loaded_files: HashMap::new(), json_data: JSONData { tabs: Vec::new() }, current_directory: String::new(), currently_playing: Vec::new(), sound_system: create_virtual_mic(), virt_outputs: Vec::new(), virt_output_index_switch: String::from("0"), virt_output_index: String::from("999"), }) .add_systems( PreStartup, setup_camera_system.before(EguiStartupSet::InitContexts), ) .add_systems(Startup, load_system) .add_systems( EguiPrimaryContextPass, (ui_system, update_ui_scale_factor_system, update_virtualmic), ) .run(); } fn update_virtualmic(mut app_state: ResMut) { if app_state.virt_outputs.is_empty() { return; } if app_state.virt_output_index != app_state.virt_output_index_switch { app_state.virt_output_index = app_state.virt_output_index_switch.clone(); #[cfg(target_os = "linux")] linux_lib::move_index_to_virtualmic(app_state.virt_output_index_switch.clone()); } } fn load_system(mut app_state: ResMut) { app_state.virt_outputs = list_outputs(); if !app_state.virt_outputs.is_empty() { app_state.virt_output_index_switch = app_state.virt_outputs[0].1.clone(); } load_data(&mut app_state); } fn load_data(app_state: &mut AppState) { if std::fs::exists("data.json").expect("Failed to check existence of JSON file") { let data = std::fs::read_to_string("data.json").expect("Failed to read JSON"); app_state.json_data = serde_json::from_str(&data).expect("Failed to load JSON"); let tabs = app_state.json_data.tabs.clone(); app_state.loaded_files.clear(); if tabs.len() > 0 { app_state.current_directory = tabs[0].clone(); } for tab in tabs { app_state.loaded_files.insert(tab.clone(), Vec::new()); if std::fs::exists(tab.clone()).expect("Failed to check existence of tab directory.") { app_state.loaded_files.insert( tab.clone(), std::fs::read_dir(tab) .expect("Failed to read directory") .filter_map(|entry| { entry.ok().and_then(|e| { let path = e.path(); if path.is_file() && ALLOWED_FILE_EXTENSIONS.contains( &path .extension() .unwrap_or_default() .to_str() .expect("Could not convert extension to string"), ) { path.to_str().map(|s| s.to_string()) } else { None } }) }) .collect(), ); } } } } fn setup_camera_system(mut commands: Commands) { commands.spawn(Camera2d); } fn update_ui_scale_factor_system(egui_context: Single<(&mut EguiContextSettings, &Camera)>) { let (mut egui_settings, camera) = egui_context.into_inner(); egui_settings.scale_factor = 1.5 / camera.target_scaling_factor().unwrap_or(1.5); } fn play_sound(file_path: String, app_state: &mut AppState) { let file = File::open(&file_path).unwrap(); let src = Decoder::new(BufReader::new(file)).unwrap(); let sink = Sink::connect_new(&app_state.sound_system.output_stream.mixer()); let length = src .total_duration() .expect("Could not get source duration") .as_secs_f32(); sink.append(src); sink.play(); #[cfg(target_os = "windows")] { let file2 = File::open(&file_path).unwrap(); let src2 = Decoder::new(BufReader::new(file2)).unwrap(); let normal_sink = Sink::connect_new(&app_state.sound_system.normal_output_stream.mixer()); normal_sink.append(src2); app_state.currently_playing.push(PlayingSound { file_path: file_path.clone(), length, sink, normal_sink }); return; } app_state.currently_playing.push(PlayingSound { file_path: file_path.clone(), length, sink, sink // sink twice cuz rust is dumb shit and doesnt understand im already returning }) } fn ui_system(mut contexts: EguiContexts, mut app_state: ResMut) -> Result { let ctx = contexts.ctx_mut()?; egui::TopBottomPanel::top("top_panel").show(ctx, |ui| { ui.heading("csd4ni3l Soundboard"); }); egui::SidePanel::right("tools").show(ctx, |ui| { ui.heading("Tools"); ui.separator(); let available_width = ui.available_width(); let available_height = ui.available_height(); let outputs = app_state.virt_outputs.clone(); #[allow(unused_mut)] let mut mic_name = "Select inside apps".to_string(); #[cfg(target_os = "linux")] { let output_index = app_state.virt_output_index.clone(); let output_sink = linux_lib::get_sink_by_index("source-outputs", output_index); if let Some(app_name) = output_sink["properties"]["application.name"].as_str() { mic_name = app_name.to_string(); } } ui.label("Virtual Mic Output"); egui::ComboBox::from_id_salt("Virtual Mic Output") .selected_text(mic_name) .width(available_width) .height(available_height / 15.0) .show_ui(ui, |ui| { for output in &outputs { ui.selectable_value( &mut app_state.virt_output_index_switch, output.1.clone(), output.0.clone(), ); } }); if ui .add_sized( [available_width, available_height / 15.0], egui::Button::new("Add folder"), ) .clicked() { if let Some(folder) = rfd::FileDialog::new().pick_folder() { if let Some(path_str) = folder.to_str() { println!("Selected: {}", path_str); app_state.json_data.tabs.push(path_str.to_string()); std::fs::write( "data.json", serde_json::to_string(&app_state.json_data) .expect("Could not convert JSON to string"), ) .expect("Could not write to JSON file"); load_data(&mut app_state); } else { println!("Invalid path encoding!"); } } } if ui .add_sized( [available_width, available_height / 15.0], egui::Button::new("Reload content"), ) .clicked() { load_data(&mut app_state); println!("Reloaded content"); } if ui .add_sized( [available_width, available_height / 15.0], egui::Button::new("Youtube downloader"), ) .clicked() { println!("Youtube downloader!"); } if ui .add_sized( [available_width, available_height / 15.0], egui::Button::new("Reload sound system"), ) .clicked() { app_state.currently_playing.clear(); app_state.sound_system = reload_sound(); println!("Sucessfully reloaded sound system!"); } }); egui::TopBottomPanel::bottom("currently_playing").show(ctx, |ui| { ui.horizontal(|ui| { if app_state.sound_system.paused { ui.heading("Paused"); } else { ui.heading("Playing"); } ui.vertical(|ui| { for playing_sound in &app_state.currently_playing { ui.label(format!( "{} - {:.2} / {:.2}", playing_sound.file_path, playing_sound.sink.get_pos().as_secs_f32(), playing_sound.length )); } }) }); }); egui::CentralPanel::default().show(ctx, |ui| { let available_height = ui.available_height(); ui.horizontal(|ui| { let available_width = ui.available_width(); let current_directories = app_state.loaded_files.keys().cloned().collect::>(); for directory in current_directories.clone() { let mut button = egui::Button::new(&directory); if directory == app_state.current_directory { button = button.fill(Color32::BLACK); } if ui .add_sized( [ available_width / current_directories.len() as f32, available_height / 15.0, ], button, ) .clicked() { app_state.current_directory = directory; }; } }); ui.add_space(available_height / 50.0); if app_state.current_directory.chars().count() > 0 { let files = app_state .loaded_files .get(&app_state.current_directory) .cloned() .unwrap_or_default(); egui::ScrollArea::vertical().show(ui, |ui| { for element in files { if let Some(filename) = element.split("/").collect::>().last() { if ui .add_sized( [ui.available_width(), available_height / 15.0], egui::Button::new(*filename), ) .clicked() { let path = Path::new(&app_state.current_directory) .join(filename) .to_string_lossy() .to_string(); play_sound(path, &mut app_state); } } } }); } }); app_state.currently_playing.retain(|playing_sound| { playing_sound.sink.get_pos().as_secs_f32() <= (playing_sound.length - 0.01) // 0.01 offset needed here because of floating point errors and so its not exact }); Ok(()) }