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