mirror of
https://github.com/csd4ni3l/soundboard.git
synced 2026-06-02 11:46:09 +02:00
Format code using Zed auto-formatter, display stderr along with stdout
in youtube downloader, use mp3 template instead of -x and --audio-format in the command,
This commit is contained in:
+193
-106
@@ -1,7 +1,19 @@
|
|||||||
use bevy::{log::Level, prelude::*};
|
use bevy::{log::Level, prelude::*};
|
||||||
use bevy_egui::{EguiContextSettings, EguiContexts, EguiPrimaryContextPass, EguiStartupSet, egui::{self, Context, TextBuffer, Ui, ecolor::Color32}};
|
use bevy_egui::{
|
||||||
|
EguiContextSettings, EguiContexts, EguiPrimaryContextPass, EguiStartupSet,
|
||||||
|
egui::{self, Context, TextBuffer, Ui, ecolor::Color32},
|
||||||
|
};
|
||||||
|
|
||||||
use std::{collections::HashMap, fs::{File, create_dir, exists, rename}, io::{BufReader, Read, Seek}, path::Path, process::{Command, Stdio}, sync::{Arc, Mutex}, thread, time::Instant};
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
fs::{File, create_dir, exists, rename},
|
||||||
|
io::{BufReader, Read, Seek},
|
||||||
|
path::Path,
|
||||||
|
process::{Command, Stdio},
|
||||||
|
sync::{Arc, Mutex},
|
||||||
|
thread,
|
||||||
|
time::Instant,
|
||||||
|
};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -46,7 +58,7 @@ struct YoutubeDownloaderState {
|
|||||||
current_filename: String,
|
current_filename: String,
|
||||||
download_directory: String,
|
download_directory: String,
|
||||||
yt_dlp_running: bool,
|
yt_dlp_running: bool,
|
||||||
yt_dlp_stdout_text: Arc<Mutex<String>>
|
yt_dlp_stdout_text: Arc<Mutex<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Resource)]
|
#[derive(Resource)]
|
||||||
@@ -60,7 +72,7 @@ struct AppState {
|
|||||||
is_virt_output_used: HashMap<String, bool>,
|
is_virt_output_used: HashMap<String, bool>,
|
||||||
last_virt_output_update: Instant,
|
last_virt_output_update: Instant,
|
||||||
current_view: String,
|
current_view: String,
|
||||||
youtube_downloader_state: YoutubeDownloaderState
|
youtube_downloader_state: YoutubeDownloaderState,
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"];
|
const ALLOWED_FILE_EXTENSIONS: [&str; 4] = ["mp3", "wav", "flac", "ogg"];
|
||||||
@@ -163,8 +175,8 @@ fn main() {
|
|||||||
current_filename: String::new(),
|
current_filename: String::new(),
|
||||||
download_directory: String::new(),
|
download_directory: String::new(),
|
||||||
yt_dlp_running: false,
|
yt_dlp_running: false,
|
||||||
yt_dlp_stdout_text: Arc::new(Mutex::new(String::new()))
|
yt_dlp_stdout_text: Arc::new(Mutex::new(String::new())),
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
.add_systems(
|
.add_systems(
|
||||||
PreStartup,
|
PreStartup,
|
||||||
@@ -179,7 +191,8 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn update(mut app_state: ResMut<AppState>) {
|
fn update(mut app_state: ResMut<AppState>) {
|
||||||
#[cfg(target_os = "linux")] {
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
if app_state.last_virt_output_update.elapsed().as_secs_f32() >= 1.5 {
|
if app_state.last_virt_output_update.elapsed().as_secs_f32() >= 1.5 {
|
||||||
app_state.last_virt_output_update = Instant::now();
|
app_state.last_virt_output_update = Instant::now();
|
||||||
app_state.virt_outputs = list_outputs();
|
app_state.virt_outputs = list_outputs();
|
||||||
@@ -187,14 +200,21 @@ fn update(mut app_state: ResMut<AppState>) {
|
|||||||
|
|
||||||
for virt_output in &app_state.virt_outputs.clone() {
|
for virt_output in &app_state.virt_outputs.clone() {
|
||||||
if !is_virt_output_used.contains_key(&virt_output.1) {
|
if !is_virt_output_used.contains_key(&virt_output.1) {
|
||||||
app_state.is_virt_output_used.insert(virt_output.1.clone(), false);
|
app_state
|
||||||
|
.is_virt_output_used
|
||||||
|
.insert(virt_output.1.clone(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if app_state.is_virt_output_used[&virt_output.1] {
|
if app_state.is_virt_output_used[&virt_output.1] {
|
||||||
linux_lib::move_output_to_sink(virt_output.1.clone(), linux_lib::get_soundboard_sink_index());
|
linux_lib::move_output_to_sink(
|
||||||
}
|
virt_output.1.clone(),
|
||||||
else {
|
linux_lib::get_soundboard_sink_index(),
|
||||||
linux_lib::move_output_to_sink(virt_output.1.clone(), linux_lib::get_default_source());
|
);
|
||||||
|
} else {
|
||||||
|
linux_lib::move_output_to_sink(
|
||||||
|
virt_output.1.clone(),
|
||||||
|
linux_lib::get_default_source(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -258,7 +278,8 @@ fn update_ui_scale_factor_system(egui_context: Single<(&mut EguiContextSettings,
|
|||||||
egui_settings.scale_factor = 1.5 / camera.target_scaling_factor().unwrap_or(1.5);
|
egui_settings.scale_factor = 1.5 / camera.target_scaling_factor().unwrap_or(1.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_duration<R>(decoder: &mut rodio::Decoder<R>) -> f32 // get_duration is needed cause some MP3 files dont provide duration metadata so we need to count
|
fn get_duration<R>(decoder: &mut rodio::Decoder<R>) -> f32
|
||||||
|
// get_duration is needed cause some MP3 files dont provide duration metadata so we need to count
|
||||||
where
|
where
|
||||||
R: Read + Seek,
|
R: Read + Seek,
|
||||||
{
|
{
|
||||||
@@ -276,7 +297,13 @@ where
|
|||||||
|
|
||||||
fn play_sound(file_path: String, app_state: &mut AppState) {
|
fn play_sound(file_path: String, app_state: &mut AppState) {
|
||||||
let file = File::open(&file_path).unwrap();
|
let file = File::open(&file_path).unwrap();
|
||||||
let mut src = Decoder::new(BufReader::new(file)).unwrap();
|
let decoded = Decoder::new(BufReader::new(file));
|
||||||
|
let mut src;
|
||||||
|
if decoded.is_ok() {
|
||||||
|
src = decoded.unwrap();
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let length = get_duration(&mut src);
|
let length = get_duration(&mut src);
|
||||||
|
|
||||||
// need to recreate since get_duration seeks to the end and nothing is left
|
// need to recreate since get_duration seeks to the end and nothing is left
|
||||||
@@ -306,12 +333,21 @@ fn play_sound(file_path: String, app_state: &mut AppState) {
|
|||||||
app_state.currently_playing.push(playing_sound);
|
app_state.currently_playing.push(playing_sound);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_virtual_mic_ui(ui: &mut Ui, app_state: &mut ResMut<AppState>, available_width: f32, available_height: f32) {
|
fn create_virtual_mic_ui(
|
||||||
#[cfg(target_os = "linux")] {
|
ui: &mut Ui,
|
||||||
|
app_state: &mut ResMut<AppState>,
|
||||||
|
available_width: f32,
|
||||||
|
available_height: f32,
|
||||||
|
) {
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
{
|
||||||
if app_state.is_virt_output_used.len() != 0 {
|
if app_state.is_virt_output_used.len() != 0 {
|
||||||
let outputs = app_state.virt_outputs.clone();
|
let outputs = app_state.virt_outputs.clone();
|
||||||
for output in &outputs {
|
for output in &outputs {
|
||||||
let current_value = *app_state.is_virt_output_used.get(&output.1).unwrap_or(&false);
|
let current_value = *app_state
|
||||||
|
.is_virt_output_used
|
||||||
|
.get(&output.1)
|
||||||
|
.unwrap_or(&false);
|
||||||
if ui
|
if ui
|
||||||
.add_sized(
|
.add_sized(
|
||||||
[available_width, available_height / 30.0],
|
[available_width, available_height / 30.0],
|
||||||
@@ -319,11 +355,13 @@ fn create_virtual_mic_ui(ui: &mut Ui, app_state: &mut ResMut<AppState>, availabl
|
|||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
*app_state.is_virt_output_used.entry(output.1.clone()).or_insert(false) = !current_value;
|
*app_state
|
||||||
|
.is_virt_output_used
|
||||||
|
.entry(output.1.clone())
|
||||||
|
.or_insert(false) = !current_value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
ui.add(egui::Button::new("No apps found to use.".to_string()));
|
ui.add(egui::Button::new("No apps found to use.".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +369,9 @@ fn create_virtual_mic_ui(ui: &mut Ui, app_state: &mut ResMut<AppState>, availabl
|
|||||||
}
|
}
|
||||||
#[allow(unreachable_code)]
|
#[allow(unreachable_code)]
|
||||||
{
|
{
|
||||||
ui.add(egui::Button::new("Unsupported. Select inside apps.".to_string()));
|
ui.add(egui::Button::new(
|
||||||
|
"Unsupported. Select inside apps.".to_string(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,42 +501,82 @@ fn main_ui(ctx: &Context, mut app_state: ResMut<AppState>) {
|
|||||||
|
|
||||||
fn download_youtube_sound(app_state: &mut ResMut<AppState>) {
|
fn download_youtube_sound(app_state: &mut ResMut<AppState>) {
|
||||||
let filename = app_state.youtube_downloader_state.current_filename.clone();
|
let filename = app_state.youtube_downloader_state.current_filename.clone();
|
||||||
let download_directory = app_state.youtube_downloader_state.download_directory.clone();
|
let download_directory = app_state
|
||||||
|
.youtube_downloader_state
|
||||||
|
.download_directory
|
||||||
|
.clone();
|
||||||
let current_url = app_state.youtube_downloader_state.current_url.clone();
|
let current_url = app_state.youtube_downloader_state.current_url.clone();
|
||||||
let stdout_text = Arc::clone(&app_state.youtube_downloader_state.yt_dlp_stdout_text);
|
let stdout_text = Arc::clone(&app_state.youtube_downloader_state.yt_dlp_stdout_text);
|
||||||
|
|
||||||
app_state.youtube_downloader_state.yt_dlp_running = true;
|
app_state.youtube_downloader_state.yt_dlp_running = true;
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut command = Command::new(get_yt_dlp_path())
|
let mut child = Command::new(get_yt_dlp_path())
|
||||||
.args(&["-x", "--audio-format", "mp3", "-o", "sound.mp3", current_url.as_str()])
|
.args(&[
|
||||||
|
"-x",
|
||||||
|
"--audio-format",
|
||||||
|
"mp3",
|
||||||
|
"-o",
|
||||||
|
"sound.mp3",
|
||||||
|
current_url.as_str(),
|
||||||
|
])
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
.stderr(Stdio::piped())
|
.stderr(Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
.expect("Failed to execute process");
|
.expect("Failed to execute process");
|
||||||
|
|
||||||
if let Some(mut stdout) = command.stdout.take() {
|
let stdout = child.stdout.take();
|
||||||
let mut buffer = String::new();
|
let stderr = child.stderr.take();
|
||||||
loop {
|
|
||||||
let mut chunk = vec![0u8; 1024];
|
let stdout_text_for_thread = stdout_text.clone();
|
||||||
match stdout.read(&mut chunk) {
|
let stdout_handle = thread::spawn(move || {
|
||||||
Ok(0) => break,
|
if let Some(mut stdout) = stdout {
|
||||||
Ok(n) => {
|
let mut buf = [0u8; 4096];
|
||||||
if let Ok(text) = String::from_utf8(chunk[..n].to_vec()) {
|
let mut buffer = String::new();
|
||||||
buffer.push_str(&text);
|
|
||||||
if let Ok(mut locked) = stdout_text.lock() {
|
loop {
|
||||||
|
match stdout.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
buffer.push_str(&String::from_utf8_lossy(&buf[..n]));
|
||||||
|
if let Ok(mut locked) = stdout_text_for_thread.lock() {
|
||||||
*locked = buffer.clone();
|
*locked = buffer.clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
let _ = command.wait();
|
|
||||||
|
let stderr_text_for_thread = stdout_text.clone();
|
||||||
|
let stderr_handle = thread::spawn(move || {
|
||||||
|
if let Some(mut stderr) = stderr {
|
||||||
|
let mut buf = [0u8; 4096];
|
||||||
|
let mut buffer = String::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match stderr.read(&mut buf) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(n) => {
|
||||||
|
buffer.push_str(&String::from_utf8_lossy(&buf[..n]));
|
||||||
|
if let Ok(mut locked) = stderr_text_for_thread.lock() {
|
||||||
|
*locked = buffer.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let _status = child.wait();
|
||||||
|
|
||||||
|
let _ = stdout_handle.join();
|
||||||
|
let _ = stderr_handle.join();
|
||||||
|
|
||||||
let path = Path::new(&download_directory).join(filename);
|
let path = Path::new(&download_directory).join(filename);
|
||||||
let _ = rename("sound.mp3", path.to_string_lossy().as_str());
|
let _ = rename("sound.mp3", path.to_string_lossy().as_ref());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,7 +588,12 @@ fn youtube_downloader_ui(ctx: &Context, mut app_state: ResMut<AppState>) {
|
|||||||
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
|
ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| {
|
||||||
ui.heading("Directory");
|
ui.heading("Directory");
|
||||||
egui::ComboBox::from_id_salt("Download Directory Selector")
|
egui::ComboBox::from_id_salt("Download Directory Selector")
|
||||||
.selected_text(app_state.youtube_downloader_state.download_directory.clone())
|
.selected_text(
|
||||||
|
app_state
|
||||||
|
.youtube_downloader_state
|
||||||
|
.download_directory
|
||||||
|
.clone(),
|
||||||
|
)
|
||||||
.width(available_width)
|
.width(available_width)
|
||||||
.height(available_height / 15.0)
|
.height(available_height / 15.0)
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
@@ -522,10 +607,18 @@ fn youtube_downloader_ui(ctx: &Context, mut app_state: ResMut<AppState>) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ui.heading("Filename");
|
ui.heading("Filename");
|
||||||
ui.add_sized([available_width, available_height / 20.0], egui::TextEdit::singleline(&mut app_state.youtube_downloader_state.current_filename));
|
ui.add_sized(
|
||||||
|
[available_width, available_height / 20.0],
|
||||||
|
egui::TextEdit::singleline(
|
||||||
|
&mut app_state.youtube_downloader_state.current_filename,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
ui.heading("Youtube URL");
|
ui.heading("Youtube URL");
|
||||||
ui.add_sized([available_width, available_height / 20.0], egui::TextEdit::singleline(&mut app_state.youtube_downloader_state.current_url));
|
ui.add_sized(
|
||||||
|
[available_width, available_height / 20.0],
|
||||||
|
egui::TextEdit::singleline(&mut app_state.youtube_downloader_state.current_url),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
if let Ok(text) = app_state.youtube_downloader_state.yt_dlp_stdout_text.lock() {
|
if let Ok(text) = app_state.youtube_downloader_state.yt_dlp_stdout_text.lock() {
|
||||||
@@ -534,10 +627,7 @@ fn youtube_downloader_ui(ctx: &Context, mut app_state: ResMut<AppState>) {
|
|||||||
|
|
||||||
if ui
|
if ui
|
||||||
.add_sized(
|
.add_sized(
|
||||||
[
|
[available_width as f32, available_height / 15.0],
|
||||||
available_width as f32,
|
|
||||||
available_height / 15.0,
|
|
||||||
],
|
|
||||||
egui::Button::new("Download Sound"),
|
egui::Button::new("Download Sound"),
|
||||||
)
|
)
|
||||||
.clicked()
|
.clicked()
|
||||||
@@ -568,8 +658,7 @@ fn draw(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Result {
|
|||||||
|
|
||||||
ui.heading("csd4ni3l Soundboard");
|
ui.heading("csd4ni3l Soundboard");
|
||||||
});
|
});
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
ui.heading("csd4ni3l Soundboard");
|
ui.heading("csd4ni3l Soundboard");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -579,72 +668,70 @@ fn draw(mut contexts: EguiContexts, mut app_state: ResMut<AppState>) -> Result {
|
|||||||
egui::TopBottomPanel::bottom("currently_playing")
|
egui::TopBottomPanel::bottom("currently_playing")
|
||||||
.exact_height(window_height * 0.1)
|
.exact_height(window_height * 0.1)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
for playing_sound in &mut app_state.currently_playing {
|
for playing_sound in &mut app_state.currently_playing {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(format!(
|
ui.label(format!(
|
||||||
"{} - {:.2} / {:.2}",
|
"{} - {:.2} / {:.2}",
|
||||||
playing_sound.file_path,
|
playing_sound.file_path,
|
||||||
playing_sound.sink.get_pos().as_secs_f32(),
|
playing_sound.sink.get_pos().as_secs_f32(),
|
||||||
playing_sound.length
|
playing_sound.length
|
||||||
));
|
));
|
||||||
let available_width = ui.available_width();
|
let available_width = ui.available_width();
|
||||||
let available_height = ui.available_height();
|
let available_height = ui.available_height();
|
||||||
if ui
|
if ui
|
||||||
.add_sized(
|
.add_sized(
|
||||||
[
|
[available_width / 2 as f32, available_height],
|
||||||
available_width / 2 as f32,
|
egui::Button::new("Stop"),
|
||||||
available_height,
|
)
|
||||||
],
|
.clicked()
|
||||||
egui::Button::new("Stop"),
|
{
|
||||||
)
|
playing_sound.to_remove = true;
|
||||||
.clicked()
|
};
|
||||||
{
|
if ui
|
||||||
playing_sound.to_remove = true;
|
.add_sized(
|
||||||
};
|
[available_width / 2 as f32, available_height],
|
||||||
if ui
|
egui::Button::new(if playing_sound.sink.is_paused() {
|
||||||
.add_sized(
|
"Resume"
|
||||||
[
|
} else {
|
||||||
available_width / 2 as f32,
|
"Pause"
|
||||||
available_height,
|
}),
|
||||||
],
|
)
|
||||||
egui::Button::new(if playing_sound.sink.is_paused() {"Resume"} else {"Pause"}),
|
.clicked()
|
||||||
)
|
{
|
||||||
.clicked()
|
if playing_sound.sink.is_paused() {
|
||||||
{
|
playing_sound.sink.play();
|
||||||
if playing_sound.sink.is_paused() {
|
} else {
|
||||||
playing_sound.sink.play();
|
playing_sound.sink.pause();
|
||||||
}
|
}
|
||||||
else {
|
};
|
||||||
playing_sound.sink.pause();
|
});
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
});
|
|
||||||
|
let available_width = ui.available_width();
|
||||||
|
let available_height = ui.available_height();
|
||||||
|
|
||||||
|
if ui
|
||||||
|
.add_sized(
|
||||||
|
[available_width, available_height / 15.0],
|
||||||
|
egui::Button::new("Stop all"),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
app_state.currently_playing.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let available_width = ui.available_width();
|
app_state.currently_playing.retain(|playing_sound| {
|
||||||
let available_height = ui.available_height();
|
// retains happen the next cycle, not in the current one because of borrowing and im lazy to fix
|
||||||
|
playing_sound.sink.get_pos().as_secs_f32() <= (playing_sound.length - 0.01)
|
||||||
if ui
|
&& !playing_sound.to_remove // 0.01 offset needed here because of floating point errors and so its not exact
|
||||||
.add_sized(
|
|
||||||
[available_width, available_height / 15.0],
|
|
||||||
egui::Button::new("Stop all"),
|
|
||||||
)
|
|
||||||
.clicked()
|
|
||||||
{
|
|
||||||
app_state.currently_playing.clear();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
app_state.currently_playing.retain(|playing_sound| { // retains happen the next cycle, not in the current one because of borrowing and im lazy to fix
|
|
||||||
playing_sound.sink.get_pos().as_secs_f32() <= (playing_sound.length - 0.01) && !playing_sound.to_remove // 0.01 offset needed here because of floating point errors and so its not exact
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if app_state.current_view == "main".to_string() {
|
if app_state.current_view == "main".to_string() {
|
||||||
main_ui(ctx, app_state);
|
main_ui(ctx, app_state);
|
||||||
}
|
} else if app_state.current_view == "youtube_downloader".to_string() {
|
||||||
else if app_state.current_view == "youtube_downloader".to_string() {
|
|
||||||
youtube_downloader_ui(ctx, app_state);
|
youtube_downloader_ui(ctx, app_state);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user