use crate::action::Action;
use crate::audio::{AudioCommand, AudioEngine, AudioStatus};
use crate::favorites::Library;
use crate::radio::Station;
use std::sync::{Arc, Mutex};
use std::collections::VecDeque;
#[derive(Debug, Clone, PartialEq)]
pub enum InputMode {
Normal,
Search,
}
#[derive(Debug, Clone, PartialEq)]
pub enum PlaybackState {
Stopped,
Connecting,
Playing,
Paused,
Error(String),
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RecordingState {
Off,
Pending,
Active,
}
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum LayoutMode {
Split, LeftOnly, RightOnly, }
pub struct App {
pub library: Library,
pub search_results: Vec<Station>,
pub selected: usize,
pub playback: PlaybackState,
pub playing_url: Option<String>,
pub volume: u8, pub muted: bool,
pub should_quit: bool,
pub input_mode: InputMode,
pub search_query: String,
pub pending_api_search: Option<String>,
pub searching_api: bool,
last_api_query: String,
pub selected_genre_idx: usize,
pub current_track: Option<String>,
pub tick_count: u64,
pub layout_mode: LayoutMode,
pub show_help: bool,
pub active_deck_page: usize,
pub song_history: VecDeque<String>,
pub show_settings: bool,
pub selected_setting_idx: usize,
pub recording_state: RecordingState,
pub active_record_filepath: Option<String>,
pub buffer_percent: u8,
pub buffer_seconds: u32,
audio: AudioEngine,
pub sample_buffer: Arc<Mutex<VecDeque<f32>>>,
pub visualizer_mode: usize, pub visualizer_peaks: Vec<f32>,
}
impl App {
pub fn new(library: Library) -> Self {
let sample_buffer = Arc::new(Mutex::new(VecDeque::with_capacity(4096)));
let audio = AudioEngine::spawn(sample_buffer.clone());
let mut app = Self {
library,
search_results: Vec::new(),
selected: 0,
playback: PlaybackState::Stopped,
playing_url: None,
volume: 80,
muted: false,
should_quit: false,
input_mode: InputMode::Normal,
search_query: String::new(),
pending_api_search: None,
searching_api: false,
last_api_query: String::new(),
selected_genre_idx: 0,
current_track: None,
tick_count: 0,
layout_mode: LayoutMode::Split,
show_help: false,
active_deck_page: 0,
song_history: VecDeque::new(),
show_settings: false,
selected_setting_idx: 0,
recording_state: RecordingState::Off,
active_record_filepath: None,
buffer_percent: 0,
buffer_seconds: 0,
audio,
sample_buffer,
visualizer_mode: 0,
visualizer_peaks: Vec::new(),
};
if app.library.settings.autoplay_last {
if let Some(ref url) = app.library.settings.last_played_url {
if let Some(pos) = app.library.stations.iter().position(|s| s.url == *url) {
app.selected = pos;
app.playing_url = Some(url.clone());
app.audio.send(AudioCommand::Play(url.clone()));
app.sync_volume();
}
}
}
app
}
pub fn poll_audio_status(&mut self) {
while let Ok(status) = self.audio.status_rx.try_recv() {
match status {
AudioStatus::TrackChanged { url, title } => {
if Some(&url) == self.playing_url.as_ref() {
let is_new = !title.is_empty() && self.current_track.as_ref() != Some(&title);
self.current_track = Some(title.clone());
if !title.is_empty() && self.song_history.back() != Some(&title) {
self.song_history.push_back(title.clone());
while self.song_history.len() > 100 {
self.song_history.pop_front();
}
}
if is_new && self.library.settings.notifications_enabled {
let mut should_notify = true;
if let Some(idle_ms) = get_user_idle_ms() {
if idle_ms > 120_000 { should_notify = false;
}
}
if should_notify {
let station_name = self.now_playing()
.map(|s| s.name.clone())
.unwrap_or_else(|| "Radio Stream".to_string());
let _ = notify_rust::Notification::new()
.summary("DriftFM ✦ Now Playing")
.body(&format!("♫ {}\nStation: {}", title, station_name))
.icon("audio-card")
.timeout(4000)
.show();
}
}
}
}
AudioStatus::RecordingStateChanged { state, filepath } => {
self.recording_state = match state {
1 => RecordingState::Pending,
2 => RecordingState::Active,
_ => RecordingState::Off,
};
self.active_record_filepath = filepath;
}
AudioStatus::BufferLevel { percent, seconds } => {
self.buffer_percent = percent;
self.buffer_seconds = seconds;
}
other => {
self.playback = match other {
AudioStatus::Playing => PlaybackState::Playing,
AudioStatus::Paused => PlaybackState::Paused,
AudioStatus::Stopped => {
self.current_track = None;
self.recording_state = RecordingState::Off;
self.active_record_filepath = None;
self.buffer_percent = 0;
self.buffer_seconds = 0;
PlaybackState::Stopped
}
AudioStatus::Error(e) => {
self.current_track = None;
self.recording_state = RecordingState::Off;
self.active_record_filepath = None;
self.buffer_percent = 0;
self.buffer_seconds = 0;
PlaybackState::Error(e)
}
AudioStatus::Connecting => {
self.current_track = None;
PlaybackState::Connecting
}
_ => self.playback.clone(),
};
}
}
}
}
pub fn visible_stations(&self) -> Vec<&Station> {
match self.input_mode {
InputMode::Normal => {
if let Some(genre) = self.library.available_genres.get(self.selected_genre_idx) {
if genre == "All" {
self.library.stations.iter().collect()
} else {
self.library.stations.iter()
.filter(|s| crate::favorites::resolve_parent_genre(&s.genre).eq_ignore_ascii_case(genre))
.collect()
}
} else {
self.library.stations.iter().collect()
}
}
InputMode::Search => self.search_results.iter().collect(),
}
}
pub fn update(&mut self, action: Action) {
if self.show_settings {
match action {
Action::NextStation => {
self.selected_setting_idx = (self.selected_setting_idx + 1) % 6;
return;
}
Action::PrevStation => {
self.selected_setting_idx = if self.selected_setting_idx == 0 {
5
} else {
self.selected_setting_idx - 1
};
return;
}
Action::PlaySelected | Action::TogglePause => {
match self.selected_setting_idx {
0 => {
self.library.settings.notifications_enabled = !self.library.settings.notifications_enabled;
}
1 => {
self.library.settings.autoplay_last = !self.library.settings.autoplay_last;
}
2 => {
self.library.settings.recording_dir = match self.library.settings.recording_dir.as_str() {
"./recordings" => "./music".to_string(),
"./music" => "./driftfm-captures".to_string(),
_ => "./recordings".to_string(),
};
}
3 => {
self.library.settings.keep_snippets = !self.library.settings.keep_snippets;
}
4 => {
self.library.settings.min_song_duration_secs = match self.library.settings.min_song_duration_secs {
30 => 60,
60 => 90,
90 => 120,
120 => 180,
_ => 30,
};
}
5 => {
use crate::ui::theme::ThemeName;
let current = ThemeName::from_key(&self.library.settings.theme);
let next = current.next();
self.library.settings.theme = next.key().to_string();
crate::ui::theme::set_active(next);
}
_ => {}
}
self.library.save();
return;
}
Action::ToggleSettings => {
self.show_settings = false;
return;
}
Action::Quit => {
self.show_settings = false;
return;
}
Action::Tick => {
self.tick_count += 1;
self.poll_audio_status();
self.update_visualizer();
return;
}
_ => { return; } }
}
match action {
Action::NextStation => {
let count = self.visible_count();
if count > 0 {
self.selected = (self.selected + 1) % count;
}
}
Action::PrevStation => {
let count = self.visible_count();
if count > 0 {
self.selected = if self.selected == 0 {
count - 1
} else {
self.selected - 1
};
}
}
Action::PlaySelected => {
let station = self.visible_stations().get(self.selected).copied().cloned();
if let Some(station) = station {
self.playing_url = Some(station.url.clone());
self.library.settings.last_played_url = Some(station.url.clone());
self.library.save();
self.audio.send(AudioCommand::Play(station.url));
self.sync_volume();
}
}
Action::TogglePause => match self.playback {
PlaybackState::Playing => {
self.audio.send(AudioCommand::Pause);
}
PlaybackState::Paused => {
self.audio.send(AudioCommand::Resume);
}
PlaybackState::Stopped | PlaybackState::Error(_) => {
self.update(Action::PlaySelected);
}
PlaybackState::Connecting => {
self.update(Action::Stop);
}
},
Action::Stop => {
self.audio.send(AudioCommand::Stop);
self.playing_url = None;
}
Action::VolumeUp => {
self.volume = (self.volume + 5).min(100);
self.muted = false;
self.sync_volume();
}
Action::VolumeDown => {
self.volume = self.volume.saturating_sub(5);
self.sync_volume();
}
Action::ToggleMute => {
self.muted = !self.muted;
self.sync_volume();
}
Action::EnterSearch => {
self.input_mode = InputMode::Search;
self.search_query.clear();
self.search_results.clear();
self.last_api_query.clear();
self.selected = 0;
}
Action::ExitSearch => {
self.input_mode = InputMode::Normal;
self.search_query.clear();
self.search_results.clear();
self.last_api_query.clear();
self.selected = 0;
self.select_playing();
}
Action::SearchInput(c) => {
self.search_query.push(c);
self.trigger_api_search();
}
Action::SearchBackspace => {
self.search_query.pop();
if self.search_query.is_empty() {
self.search_results.clear();
}
self.trigger_api_search();
}
Action::SearchConfirm => {
if let Some(station) = self.search_results.get(self.selected).cloned() {
self.library.add(station.clone());
self.playing_url = Some(station.url.clone());
self.library.settings.last_played_url = Some(station.url.clone());
self.library.save();
self.audio.send(AudioCommand::Play(station.url));
self.sync_volume();
}
self.input_mode = InputMode::Normal;
self.search_query.clear();
self.search_results.clear();
self.last_api_query.clear();
self.selected = 0;
self.select_playing();
}
Action::ToggleFavorite => {
match self.input_mode {
InputMode::Normal => {
if let Some(station) = self.visible_stations().get(self.selected) {
let url = station.url.clone();
self.library.remove(&url);
let count = self.visible_count();
if self.selected >= count && self.selected > 0 {
self.selected = count - 1;
}
}
}
InputMode::Search => {
if let Some(station) = self.search_results.get(self.selected).cloned() {
self.library.add(station);
}
}
}
}
Action::NextGenre => {
if self.input_mode == InputMode::Normal {
let count = self.library.available_genres.len();
if count > 0 {
self.selected_genre_idx = (self.selected_genre_idx + 1) % count;
self.selected = 0;
}
}
}
Action::PrevGenre => {
if self.input_mode == InputMode::Normal {
let count = self.library.available_genres.len();
if count > 0 {
self.selected_genre_idx = if self.selected_genre_idx == 0 {
count - 1
} else {
self.selected_genre_idx - 1
};
self.selected = 0;
}
}
}
Action::ToggleHelp => {
self.show_help = !self.show_help;
if self.show_help {
self.show_settings = false;
}
}
Action::ToggleSettings => {
self.show_settings = !self.show_settings;
if self.show_settings {
self.show_help = false;
}
}
Action::ToggleRecording => {
if self.playing_url.is_some() {
match self.recording_state {
RecordingState::Off => {
let category = self.now_playing()
.map(|s| s.genre.clone())
.unwrap_or_else(|| "Unknown".to_string());
let rec_dir = self.library.settings.recording_dir.clone();
let keep_snippets = self.library.settings.keep_snippets;
let min_secs = self.library.settings.min_song_duration_secs;
self.audio.send(AudioCommand::StartRecording {
recording_dir: rec_dir,
category,
keep_snippets,
min_song_duration_secs: min_secs,
});
self.recording_state = RecordingState::Pending;
}
RecordingState::Pending | RecordingState::Active => {
self.audio.send(AudioCommand::StopRecording);
self.recording_state = RecordingState::Off;
self.active_record_filepath = None;
}
}
}
}
Action::CycleLayout => {
self.layout_mode = match self.layout_mode {
LayoutMode::Split => LayoutMode::LeftOnly,
LayoutMode::LeftOnly => LayoutMode::RightOnly,
LayoutMode::RightOnly => LayoutMode::Split,
};
}
Action::NextDeckPage => {
self.active_deck_page = (self.active_deck_page + 1) % 2;
}
Action::ToggleVisualizerMode => {
self.visualizer_mode = (self.visualizer_mode + 1) % 3;
}
Action::Tick => {
self.tick_count += 1;
self.poll_audio_status();
self.update_visualizer();
}
Action::Quit => {
if self.show_help {
self.show_help = false;
} else {
self.audio.send(AudioCommand::Stop);
self.should_quit = true;
}
}
}
}
pub fn set_search_results(&mut self, results: Vec<Station>) {
self.searching_api = false;
self.search_results = results;
self.selected = 0;
}
fn trigger_api_search(&mut self) {
let query = self.search_query.trim().to_string();
if query.len() >= 2 && query != self.last_api_query {
self.pending_api_search = Some(query.clone());
self.last_api_query = query;
self.searching_api = true;
}
}
fn select_playing(&mut self) {
if let Some(ref url) = self.playing_url {
if let Some(pos) = self.visible_stations().iter().position(|s| s.url == *url) {
self.selected = pos;
}
}
}
fn sync_volume(&self) {
let vol = if self.muted {
0.0
} else {
self.volume as f32 / 100.0
};
self.audio.send(AudioCommand::SetVolume(vol));
}
pub fn now_playing(&self) -> Option<&Station> {
self.playing_url.as_ref().and_then(|url| {
self.library.stations.iter().find(|s| s.url == *url)
.or_else(|| self.search_results.iter().find(|s| s.url == *url))
})
}
pub fn visible_count(&self) -> usize {
match self.input_mode {
InputMode::Normal => {
if let Some(genre) = self.library.available_genres.get(self.selected_genre_idx) {
if genre == "All" {
self.library.stations.len()
} else {
self.library.stations.iter()
.filter(|s| crate::favorites::resolve_parent_genre(&s.genre).eq_ignore_ascii_case(genre))
.count()
}
} else {
self.library.stations.len()
}
}
InputMode::Search => self.search_results.len(),
}
}
pub fn update_visualizer(&mut self) {
if self.playback != PlaybackState::Playing {
for peak in &mut self.visualizer_peaks {
*peak = (*peak * 0.82).max(0.0);
}
return;
}
let mut samples = Vec::new();
if let Ok(buf) = self.sample_buffer.lock() {
let n = buf.len();
let window_size = 512;
if n >= window_size {
let start_idx = n - window_size;
samples.extend(buf.iter().skip(start_idx).take(window_size).copied());
} else {
samples.extend(buf.iter().copied());
while samples.len() < window_size {
samples.push(0.0);
}
}
}
if samples.is_empty() {
return;
}
let n = samples.len();
let mut windowed = vec![0.0; n];
for i in 0..n {
let w = 0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / (n - 1) as f32).cos());
windowed[i] = samples[i] * w;
}
let fft_input: Vec<Complex> = windowed.into_iter().map(Complex::from_real).collect();
let mut fft_output = vec![Complex::zero(); n];
fft_rec(&fft_input, &mut fft_output);
let num_bands = 40;
let bins_count = n / 2;
if self.visualizer_peaks.len() != num_bands {
self.visualizer_peaks = vec![0.0; num_bands];
}
for x in 0..num_bands {
let t = x as f32 / num_bands as f32;
let min_bin = 1.0_f32;
let max_bin = bins_count as f32;
let bin_start_f = min_bin * (max_bin / min_bin).powf(t);
let bin_end_f = min_bin * (max_bin / min_bin).powf((x + 1) as f32 / num_bands as f32);
let start = (bin_start_f.floor() as usize).clamp(0, bins_count - 1);
let end = (bin_end_f.ceil() as usize).clamp(start + 1, bins_count);
let mut sum = 0.0;
let mut count = 0;
for bin in fft_output.iter().take(end).skip(start) {
sum += bin.norm() / n as f32;
count += 1;
}
let avg = if count > 0 { sum / count as f32 } else { 0.0 };
let boost = 1.0 + (x as f32 / num_bands as f32) * 4.0;
let compressed = avg.sqrt();
let scaled = compressed * boost * 2.5; let target = scaled.clamp(0.0, 1.0);
let current = self.visualizer_peaks[x];
if target > current {
self.visualizer_peaks[x] = target; } else {
self.visualizer_peaks[x] = (current - 0.08).max(target).max(0.0); }
}
}
}
#[derive(Debug, Clone, Copy)]
struct Complex {
re: f32,
im: f32,
}
impl Complex {
fn new(re: f32, im: f32) -> Self {
Self { re, im }
}
fn zero() -> Self {
Self { re: 0.0, im: 0.0 }
}
fn from_real(re: f32) -> Self {
Self { re, im: 0.0 }
}
fn add(self, other: Self) -> Self {
Self {
re: self.re + other.re,
im: self.im + other.im,
}
}
fn sub(self, other: Self) -> Self {
Self {
re: self.re - other.re,
im: self.im - other.im,
}
}
fn mul(self, other: Self) -> Self {
Self {
re: self.re * other.re - self.im * other.im,
im: self.re * other.im + self.im * other.re,
}
}
fn norm(self) -> f32 {
(self.re * self.re + self.im * self.im).sqrt()
}
}
fn fft_rec(input: &[Complex], output: &mut [Complex]) {
let n = input.len();
if n <= 1 {
if n == 1 {
output[0] = input[0];
}
return;
}
let mut even = vec![Complex::zero(); n / 2];
let mut odd = vec![Complex::zero(); n / 2];
for i in 0..n / 2 {
even[i] = input[2 * i];
odd[i] = input[2 * i + 1];
}
let mut even_fft = vec![Complex::zero(); n / 2];
let mut odd_fft = vec![Complex::zero(); n / 2];
fft_rec(&even, &mut even_fft);
fft_rec(&odd, &mut odd_fft);
for k in 0..n / 2 {
let angle = -2.0 * std::f32::consts::PI * (k as f32) / (n as f32);
let twiddle = Complex::new(angle.cos(), angle.sin());
let t = twiddle.mul(odd_fft[k]);
output[k] = even_fft[k].add(t);
output[k + n / 2] = even_fft[k].sub(t);
}
}
#[cfg(target_os = "windows")]
fn get_user_idle_ms() -> Option<u64> {
#[repr(C)]
#[allow(clippy::upper_case_acronyms)] struct LASTINPUTINFO {
cb_size: u32,
dw_time: u32,
}
extern "system" {
fn GetLastInputInfo(plii: *mut LASTINPUTINFO) -> i32;
fn GetTickCount64() -> u64;
}
let mut lii = LASTINPUTINFO {
cb_size: std::mem::size_of::<LASTINPUTINFO>() as u32,
dw_time: 0,
};
unsafe {
if GetLastInputInfo(&mut lii) != 0 {
let tick = GetTickCount64();
let last_input_64 = lii.dw_time as u64;
let tick_low = tick & 0xFFFF_FFFF;
let idle = if tick_low >= last_input_64 {
tick_low - last_input_64
} else {
(0x1_0000_0000u64 - last_input_64) + tick_low
};
Some(idle)
} else {
None
}
}
}
#[cfg(not(target_os = "windows"))]
fn get_user_idle_ms() -> Option<u64> {
None
}