Skip to content
89 changes: 50 additions & 39 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use std::time::{Duration, Instant};

use iced::widget::{button, container, row, scrollable, space, text};
use iced::{Element, Length, Point, Subscription, Task, Theme};
Expand All @@ -18,10 +18,14 @@ use crate::ui::side_panel::{PanelAnim, PanelFlourish};
use crate::ui::sound_grid;
use crate::ui::theme::{self, Hh};
use crate::ui::{now_playing, search_bar, slot_manager};
use notices::{Notice, NoticeId, NoticeQueue};

/// Play-dispatch coordination (`request_play` / `handle_decoded` /
/// `start_playback`), extracted to keep this file from growing (#151).
mod macros;
#[cfg(test)]
mod notice_tests;
pub(crate) mod notices;
/// Panel animation state transitions extracted from the Iced update loop (#144).
mod panels;
mod playback;
Expand Down Expand Up @@ -55,6 +59,9 @@ pub enum Message {
TrayEvent(TrayEvent),
TrayPoll,
AudioEvent(AudioEvent),
RaiseNotice(Notice),
DismissNotice(NoticeId),
NoticeTick(Instant),
PlaySound(String),
StopAll,
StartRecording,
Expand Down Expand Up @@ -210,9 +217,8 @@ pub struct HonkHonk {
pub monitor_devices: Vec<(String, String)>,
pub input_devices: Vec<(String, String)>,
shortcut_config: crate::shortcuts::config_ui::ShortcutConfigService,
/// One-time notice surfaced on first run when the persistent virtual mic was
/// created programmatically (issue #49). `None` until `SourceFirstRun` fires.
source_notice: Option<String>,
/// User-visible in-window notices raised from app/audio events.
notices: NoticeQueue,
/// Per-sound metadata: favorites, per-sound volume, display names.
pub(crate) sound_meta: SoundMetaStore,
/// Master persistence switch. When `false`, every disk write —
Expand Down Expand Up @@ -431,7 +437,7 @@ impl HonkHonk {
monitor_devices: Vec::new(),
input_devices: Vec::new(),
shortcut_config: crate::shortcuts::config_ui::ShortcutConfigService::new(),
source_notice: None,
notices: NoticeQueue::new(),
sound_meta: SoundMetaStore::load(),
persist: true,
config_load_failed: false,
Expand Down Expand Up @@ -493,7 +499,7 @@ impl HonkHonk {
monitor_devices: Vec::new(),
input_devices: Vec::new(),
shortcut_config: crate::shortcuts::config_ui::ShortcutConfigService::new(),
source_notice: None,
notices: NoticeQueue::new(),
sound_meta: SoundMetaStore::default(),
persist: false,
config_load_failed: false,
Expand Down Expand Up @@ -610,11 +616,8 @@ impl HonkHonk {
self.shortcuts_warning_dismissed
}

/// First-run persistent-mic notice text, if one was surfaced this session
/// (issue #49). Returns `None` until a `SourceFirstRun` event fires. A UI
/// banner can render this; rendering is intentionally out of scope here.
pub fn source_notice(&self) -> Option<&str> {
self.source_notice.as_deref()
pub(crate) fn notices(&self) -> &NoticeQueue {
&self.notices
}

pub fn sound_meta(&self) -> &SoundMetaStore {
Expand Down Expand Up @@ -748,11 +751,14 @@ impl HonkHonk {
}
AudioEvent::Error(e) => {
tracing::error!(error = %e, "audio error");
self.notices
.push(Notice::error("Audio error", e.to_string()), Instant::now());
}
AudioEvent::SourceFirstRun { confd_written } => {
let notice = source_first_run_notice(confd_written);
tracing::info!(notice = %notice, "source first-run notice");
self.source_notice = Some(notice);
let body = source_first_run_notice(confd_written);
tracing::info!(notice = %body, "source first-run notice");
self.notices
.push(Notice::info("HonkHonk Mic created", body), Instant::now());
}
AudioEvent::OutputDevicesChanged(devices) => {
if let Some(ref target) = self.config.monitor_device.clone() {
Expand Down Expand Up @@ -796,6 +802,18 @@ impl HonkHonk {
}
Task::none()
}
Message::RaiseNotice(notice) => {
self.notices.push(notice, Instant::now());
Task::none()
}
Message::DismissNotice(id) => {
self.notices.dismiss(id);
Task::none()
}
Message::NoticeTick(now) => {
self.notices.expire(now);
Task::none()
}
Message::PlaySound(sound_id) => {
if let Some(sound) = self.sounds.iter().find(|s| s.id == sound_id).cloned() {
self.request_play(&sound, false)
Expand Down Expand Up @@ -1451,8 +1469,7 @@ impl HonkHonk {
pub fn subscription(&self) -> Subscription<Message> {
let shortcuts = Subscription::run(shortcuts_stream_sub_none);

let tray_poll =
iced::time::every(std::time::Duration::from_millis(100)).map(|_| Message::TrayPoll);
let tray_poll = iced::time::every(Duration::from_millis(100)).map(|_| Message::TrayPoll);

let events = iced::event::listen_with(|event, _, _window_id| match event {
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
Expand Down Expand Up @@ -1492,6 +1509,10 @@ impl HonkHonk {
subs.push(iced::window::frames().map(Message::Frame));
}

if self.notices.has_expiring() {
subs.push(iced::time::every(Duration::from_millis(250)).map(Message::NoticeTick));
}

Subscription::batch(subs)
}

Expand Down Expand Up @@ -1669,7 +1690,7 @@ impl HonkHonk {
}

pub fn view(&self) -> Element<'_, Message> {
match self.view_mode {
let base = match self.view_mode {
ViewMode::Main => self.view_main(),
ViewMode::SlotManager => {
let t = self.config.theme;
Expand All @@ -1685,7 +1706,19 @@ impl HonkHonk {
)
}
ViewMode::Settings => crate::ui::settings::view_settings(self, self.config.theme),
};

let mut layers = vec![base];
if let Some(notice_layer) =
crate::ui::notice::view_notice_layer(self.notices(), self.config.theme)
{
layers.push(notice_layer);
}

iced::widget::Stack::with_children(layers)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}

Expand Down Expand Up @@ -1854,28 +1887,6 @@ mod tests {
assert!(!app.now_playing.has_playhead());
}

#[test]
fn source_first_run_written_sets_persistent_notice() {
let mut app = HonkHonk::new_for_test();
assert!(app.source_notice().is_none());
let _ = app.update(Message::AudioEvent(AudioEvent::SourceFirstRun {
confd_written: true,
}));
let notice = app.source_notice().expect("notice set");
assert!(notice.contains("persist"));
assert!(notice.contains("HonkHonk Mic"));
}

#[test]
fn source_first_run_not_written_sets_session_notice() {
let mut app = HonkHonk::new_for_test();
let _ = app.update(Message::AudioEvent(AudioEvent::SourceFirstRun {
confd_written: false,
}));
let notice = app.source_notice().expect("notice set");
assert!(notice.contains("this session"));
}

#[test]
fn audio_event_playback_started_sets_playing() {
let mut app = HonkHonk::new_for_test();
Expand Down
70 changes: 70 additions & 0 deletions src/app/notice_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
use std::time::Instant;

use super::notices::{Notice, NoticeLevel};
use super::{HonkHonk, Message};
use crate::audio::{AudioEvent, EngineErrorEvent};

#[test]
fn source_first_run_written_queues_persistent_notice() {
let mut app = HonkHonk::new_for_test();
assert!(app.notices().is_empty());

let _ = app.update(Message::AudioEvent(AudioEvent::SourceFirstRun {
confd_written: true,
}));

let notice = app.notices().front().expect("notice set");
assert_eq!(notice.notice.level, NoticeLevel::Info);
assert!(notice.notice.body.contains("persist"));
assert!(notice.notice.body.contains("HonkHonk Mic"));
}

#[test]
fn source_first_run_not_written_queues_session_notice() {
let mut app = HonkHonk::new_for_test();

let _ = app.update(Message::AudioEvent(AudioEvent::SourceFirstRun {
confd_written: false,
}));

let notice = app.notices().front().expect("notice set");
assert_eq!(notice.notice.level, NoticeLevel::Info);
assert!(notice.notice.body.contains("this session"));
}

#[test]
fn raise_notice_message_queues_notice() {
let mut app = HonkHonk::new_for_test();

let _ = app.update(Message::RaiseNotice(Notice::warning(
"Shortcut unavailable",
"The portal is not running.",
)));

let notice = app.notices().front().expect("notice queued");
assert_eq!(notice.notice.level, NoticeLevel::Warning);
assert_eq!(notice.notice.title, "Shortcut unavailable");
}

#[test]
fn audio_error_event_queues_persistent_error_notice() {
let mut app = HonkHonk::new_for_test();

let _ = app.update(Message::AudioEvent(AudioEvent::Error(
EngineErrorEvent::VirtualSinkNotRegistered,
)));

let notice = app.notices().front().expect("notice queued");
assert_eq!(notice.notice.level, NoticeLevel::Error);
assert_eq!(notice.notice.title, "Audio error");
assert!(notice.notice.body.contains("virtual sink"));
let id = notice.id;

let _ = app.update(Message::NoticeTick(
Instant::now() + Notice::DEFAULT_TIMEOUT * 4,
));
assert!(app.notices().front().is_some());

let _ = app.update(Message::DismissNotice(id));
assert!(app.notices().is_empty());
}
Loading
Loading