From d2e09ecebc83281d16dfa081d499d48608285046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabor=20K=C3=B6rber?= <gab@g4b.org> Date: Sat, 20 Jan 2024 21:09:44 +0100 Subject: [PATCH] code: adding watch command, making rodio play available, switching to fomantic-ui and implementing fullscreen and aspect ratios --- Justfile | 3 ++ src/main.rs | 16 +++--- src/vbplay.rs | 86 +++++++++++++++++++---------- templates/index.jinja | 123 ++++++++++++++++++++++++++++++++++-------- 4 files changed, 171 insertions(+), 57 deletions(-) diff --git a/Justfile b/Justfile index 4a0e2a2..8ad9ab9 100644 --- a/Justfile +++ b/Justfile @@ -9,3 +9,6 @@ bin args='': hello: @echo "Hello, world!" + +watch: + cargo watch -c -q -w src -w templates -x run diff --git a/src/main.rs b/src/main.rs index 124cde5..f7161c1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,7 +9,7 @@ use log::{debug, info}; use serde::Serialize; use std::sync::{Arc, Mutex}; -use vbplay::{AudioThread, DeviceSelection}; +use vbplay::{AudioThread, DeviceSelection, SoundDecoder}; mod soundclips; mod state; @@ -21,15 +21,19 @@ async fn main() { jinja.set_loader(path_loader("templates")); let template_engine = Engine::from(jinja); - let audio = vbplay::audio_thread(DeviceSelection::FindByPattern( - ".*VB-Audio Virtual Cable.*".to_owned(), - )); + let audio = vbplay::audio_thread( + DeviceSelection::FindByPattern(".*VB-Audio Virtual Cable.*".to_owned()), + SoundDecoder::Detect, + ); let audio: Arc<Mutex<AudioThread>> = Arc::new(Mutex::new(audio)); let app_state = AppState { engine: template_engine, - clips: soundclips::scan_directory_for_clips("E:/sounds/soundboard/all", &["mp3"]) - .expect("No Soundclips found."), + clips: soundclips::scan_directory_for_clips( + "E:/sounds/soundboard/all", + &["mp3", "ogg", "wav", "flac"], + ) + .expect("No Soundclips found."), player: audio, playback: None, }; diff --git a/src/vbplay.rs b/src/vbplay.rs index 56a7bf5..a6c26c9 100644 --- a/src/vbplay.rs +++ b/src/vbplay.rs @@ -4,11 +4,18 @@ use log::{debug, error, info}; use minimp3::{Decoder, Error}; use regex::Regex; use std::fs::File; -use std::io::Cursor; use std::io::Read; +use std::io::{BufReader, Cursor}; use std::sync::mpsc::{self, Sender}; use std::thread; +#[derive(Clone)] +pub enum SoundDecoder { + Detect, + Mp3Mini, + Rodio, +} + pub enum Command { Play(String), Stop, @@ -57,7 +64,7 @@ fn select_device_by_id(index: usize) -> Option<Device> { pub type AudioThread = Sender<Command>; -pub fn audio_thread(select_device: DeviceSelection) -> AudioThread { +pub fn audio_thread(select_device: DeviceSelection, select_decoder: SoundDecoder) -> AudioThread { let (tx, rx) = mpsc::channel(); thread::spawn(move || { @@ -76,7 +83,7 @@ pub fn audio_thread(select_device: DeviceSelection) -> AudioThread { for command in rx { match command { Command::Play(file_name) => { - play_file(&sink, file_name); + play_file(&sink, file_name, &select_decoder); } Command::Stop => { sink.stop(); @@ -88,31 +95,52 @@ pub fn audio_thread(select_device: DeviceSelection) -> AudioThread { tx } -fn play_file(sink: &rodio::Sink, file_name: String) { - let mut mp3_file = File::open(file_name).unwrap(); - let mut mp3_data = Vec::new(); - mp3_file.read_to_end(&mut mp3_data).unwrap(); - - // Iterate over the MP3 frames and play them - let mut decoder = Decoder::new(Cursor::new(mp3_data)); - loop { - match decoder.next_frame() { - Ok(frame) => { - let source = rodio::buffer::SamplesBuffer::new( - 2, - frame.sample_rate.try_into().unwrap(), - &*frame.data, - ); - sink.append(source); - } - Err(Error::Eof) => { - debug!("EOF"); - break; - } - Err(e) => { - error!("{:?}", e); - break; - } - } +fn detect_decoder(file_name: &str, sound_decoder: &SoundDecoder) -> SoundDecoder { + // TODO: File detection via ending or whitelisting? + // This function MUST NOT return SoundDecoder::Detect + match sound_decoder { + SoundDecoder::Detect => SoundDecoder::Rodio, + other => other.clone(), + } +} + +fn play_file(sink: &rodio::Sink, file_name: String, sound_decoder: &SoundDecoder) { + match detect_decoder(&file_name, sound_decoder) { + SoundDecoder::Mp3Mini => { + // MP3 Mini provides low latency playback + let mut sound_file = File::open(file_name).unwrap(); + let mut file_data = Vec::new(); + sound_file.read_to_end(&mut file_data).unwrap(); + // Iterate over the MP3 frames and play them + let mut decoder = Decoder::new(Cursor::new(file_data)); + loop { + match decoder.next_frame() { + Ok(frame) => { + let source = rodio::buffer::SamplesBuffer::new( + 2, + frame.sample_rate.try_into().unwrap(), + &*frame.data, + ); + sink.append(source); + } + Err(Error::Eof) => { + debug!("EOF"); + break; + } + Err(e) => { + error!("{:?}", e); + break; + } + } + } + } + SoundDecoder::Rodio => { + // Rodio currently supports Ogg, Mp3, WAV and Flac + let file = File::open(&file_name).unwrap(); + let source = rodio::Decoder::new(BufReader::new(file)).expect("Failed to open Decoder"); + sink.append(source); + //sink.sleep_until_end(); + } + SoundDecoder::Detect => error!("This should never happen"), } } diff --git a/templates/index.jinja b/templates/index.jinja index f99738f..235fde5 100644 --- a/templates/index.jinja +++ b/templates/index.jinja @@ -5,15 +5,48 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> - <!-- UIkit CSS --> - <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.17.11/dist/css/uikit.min.css" /> + <script src="https://unpkg.com/jquery@3.7.1/dist/jquery.js"></script> + <link rel="stylesheet" type="text/css" href="https://unpkg.com/fomantic-ui@2.9.3/dist/semantic.min.css"> + <script src="https://unpkg.com/fomantic-ui@2.9.3/dist/semantic.min.js"></script> - <!-- UIkit JS --> - <script src="https://cdn.jsdelivr.net/npm/uikit@3.17.11/dist/js/uikit.min.js"></script> - <script src="https://cdn.jsdelivr.net/npm/uikit@3.17.11/dist/js/uikit-icons.min.js"></script> - - <title>Play</title> + <title>Soundboard</title> <script> + function setLandscape() { + if (screen.orientation && screen.orientation.lock) { + screen.orientation.lock('landscape').then(() => { + console.log("Landscape mode activated"); + }).catch((error) => { + console.error("Landscape mode failed:", error); + }); + } else { + console.log("Screen Orientation API not supported"); + } + } + + function setPortrait() { + if (screen.orientation && screen.orientation.lock) { + screen.orientation.lock('portrait').then(() => { + console.log("Portrait mode activated"); + }).catch((error) => { + console.error("Portrait mode failed:", error); + }); + } else { + console.log("Screen Orientation API not supported"); + } + } + + function toggleFullScreen() { + if (!document.fullscreenElement) { + document.documentElement.requestFullscreen().catch((error) => { + console.error("Error attempting to enable full-screen mode:", error); + }); + } else { + if (document.exitFullscreen) { + document.exitFullscreen(); + } + } + } + function play(hash) { fetch("/play/" + hash); } @@ -22,26 +55,72 @@ fetch("/stop"); } </script> + <style type="text/css"> + :root { + --left-column-width: 98px; + /* Define a CSS variable for the width */ + } + + .left-column { + width: var(--left-column-width) !important; + height: 100%; + position: fixed; + /* Fix the position relative to the viewport */ + top: 0; + /* Align the top edge with the top of the viewport */ + bottom: 0; + /* Align the bottom edge with the bottom of the viewport */ + overflow-y: auto; + /* Add scroll to the left column if content overflows */ + + background-color: rgb(27, 28, 29); + } + + .right-column { + margin-left: var(--left-column-width) !important; + } + </style> </head> <body> - <div id="main" class="uk-container"> - <div id="categories"> - <span class="category">Category 1</span> - <!-- More categories --> - <button class="more">More</button> - </div> - <div class="uk-text-center" uk-grid> - <div id="soundclips"> - {% for clip in clips %} - <button class="uk-button uk-button-default" onclick="play('{{clip.hash}}')">{{clip.file_name}}</button> - {% endfor %} - </div> - </div> - <div class="player"> - <button onclick="stop()">Stop.</button> + + <div class="left-column column fixed"> + <div class="ui inverted labeled icon inline vertical compact mini menu" style="min-width: 96.75px;"> + <a class="item" onclick="stop()"> + <i class="stop icon"></i> + Stop + </a> + <a class="item" onclick="toggleFullScreen()"> + <i class="expand layout icon"></i> + Fullscreen + </a> + <a class="item" onclick="setLandscape()"> + <i class="tv icon"></i> + Landscape + </a> + <a class="item" onclick="setPortrait()"> + <i class="mobile alternate icon"></i> + Portrait + </a> </div> </div> + <div class="right-column column"> + + <div class="ui basic segment"> + + <div class="ui wrapped compact wrapping spaced /**buttons"> + {% for clip in clips %} + <button class="ui {{ loop.cycle('red', 'blue', 'green', 'violet', 'orange') }} basic button" + onclick="play('{{clip.hash}}')">{{clip.file_name}}</button> + {% endfor %} + + </div> + </div> + </div> + </div> + + + </body> </html> \ No newline at end of file