diff --git a/src/app/mod.rs b/src/app/mod.rs index b40cfe8..dc14de6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -393,6 +393,15 @@ pub enum AppMessage { PauseAudio, ResumeAudio, SeekAudio(crate::files::AttachmentId, f32), + /// Adjust the universal inline-clip playback volume (`1.0` = unity) from the + /// master slider. Persisted to config; applied live only in universal mode. + SetClipVolume(f32), + /// Adjust volume from a clip's own row slider. In universal mode this drives + /// the shared level; otherwise it sets just that clip's in-memory level. + SetClipVolumeFor(crate::files::AttachmentId, f32), + /// Toggle whether one universal level governs every clip (checked) or each + /// clip keeps its own level (unchecked). + ToggleUniversalClipVolume(bool), /// Redraw cadence while an inline clip is active. AudioTick, /// Send the current chat input line (Enter or the Send button). @@ -582,6 +591,10 @@ pub struct AppState { /// the call capture/mixer path. clip_player: ClipPlayer, clip_status: SharedClipStatus, + /// Per-clip playback gain used when universal clip volume is disabled + /// (`config.clip_volume_universal == false`). In-memory only; absent clips + /// default to unity. Universal mode ignores this and uses `config.clip_volume`. + clip_volumes: HashMap, /// Last known window size, tracked so divider clamps stay valid on resize. /// (The divider positions themselves are persisted in `config`.) window_size: Size, @@ -781,7 +794,7 @@ impl Default for AppState { let selected_output = output_devices.iter().find(|d| d.name == config.output_device).cloned(); let background_image = load_background_bytes(&config); - let (clip_player, clip_status) = ClipPlayer::new(); + let (clip_player, clip_status) = ClipPlayer::new(config.clip_volume); Self { // Pre-fill the nickname with the last one used (or "Peer" by default). @@ -819,6 +832,7 @@ impl Default for AppState { invalid_audio: HashSet::new(), clip_player, clip_status, + clip_volumes: HashMap::new(), chat_input: String::new(), window_size: Size::new(ww, wh), layout_picker_open: false, @@ -2200,6 +2214,38 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { state.clip_player.seek(seek_target(fraction, total)); } } + AppMessage::SetClipVolume(volume) => { + // Master slider: always stores the universal level, but only the + // active player is nudged when universal mode is actually on. + let volume = volume.clamp(0.0, 2.0); + state.config.clip_volume = volume; + state.config.save(); + if state.config.clip_volume_universal { + state.clip_player.set_volume(volume); + } + } + AppMessage::SetClipVolumeFor(id, volume) => { + let volume = volume.clamp(0.0, 2.0); + if state.config.clip_volume_universal { + state.config.clip_volume = volume; + state.config.save(); + state.clip_player.set_volume(volume); + } else { + state.clip_volumes.insert(id, volume); + // Only the clip the user is dragging should react immediately. + if status_snapshot(&state.clip_status).playing_id == Some(id) { + state.clip_player.set_volume(volume); + } + } + } + AppMessage::ToggleUniversalClipVolume(on) => { + state.config.clip_volume_universal = on; + state.config.save(); + // Reapply the now-effective level to whatever is currently playing. + if let Some(id) = status_snapshot(&state.clip_status).playing_id { + state.clip_player.set_volume(effective_clip_volume(state, id)); + } + } AppMessage::AudioTick => { let clip = status_snapshot(&state.clip_status); if let Some(failure) = clip.failure { @@ -2512,6 +2558,16 @@ fn find_attachment_source( /// Validate cached bytes and hand them to the independent clip player. A false /// filename hint falls back to the generic file chip without reaching rodio. +/// Resolve the gain to use for clip `id`: the shared universal level, or the +/// clip's own stored level (defaulting to unity) when universal mode is off. +fn effective_clip_volume(state: &AppState, id: crate::files::AttachmentId) -> f32 { + if state.config.clip_volume_universal { + state.config.clip_volume + } else { + state.clip_volumes.get(&id).copied().unwrap_or(1.0) + } +} + fn play_ready_audio(state: &mut AppState, key: AttachmentKey) { let Some(AttachmentState::Ready(data)) = state.attachments.get(&key) else { return; @@ -2521,6 +2577,9 @@ fn play_ready_audio(state: &mut AppState, key: AttachmentKey) { let bytes = data.clone(); state.invalid_audio.remove(&id); state.clip_player.play(id, bytes); + // Apply this clip's effective gain; the command lands after Play so it + // takes effect on the freshly connected player. + state.clip_player.set_volume(effective_clip_volume(state, id)); } else { state.invalid_audio.insert(id); state.status_message = "This attachment is not valid supported audio.".to_string(); @@ -4805,6 +4864,17 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { text(format!("{elapsed} / {duration}")) .size(11) .color(color_subtext), + // Per-clip volume. In universal mode this shows + // and drives the shared level; otherwise it is + // this clip's own remembered level. + text("🔊").size(12).color(color_subtext), + slider( + 0.0..=2.0, + effective_clip_volume(state, att.id), + move |v| AppMessage::SetClipVolumeFor(att.id, v), + ) + .step(0.01) + .width(iced::Length::Fixed(80.0)), ] .spacing(8) .align_y(iced::alignment::Vertical::Center), @@ -4868,8 +4938,26 @@ fn view(state: &AppState) -> Element<'_, AppMessage> { ] .spacing(8) .align_y(iced::alignment::Vertical::Center); - let chat_inner = column![ + // Chat header: title on the left, the universal-volume control on the + // right. The master slider drives every clip when "Universal" is checked; + // when unchecked each clip keeps its own level and this slider is inert. + let universal = state.config.clip_volume_universal; + let chat_header = row![ text("Chat").size(16).color(color_blue), + horizontal_space(), + checkbox(universal) + .label("Universal volume") + .text_size(12) + .on_toggle(AppMessage::ToggleUniversalClipVolume), + text("🔊").size(13).color(color_subtext), + slider(0.0..=2.0, state.config.clip_volume, AppMessage::SetClipVolume) + .step(0.01) + .width(iced::Length::Fixed(110.0)), + ] + .spacing(10) + .align_y(iced::alignment::Vertical::Center); + let chat_inner = column![ + chat_header, chat_scroll, chat_input_row, ] diff --git a/src/audio/clip_player.rs b/src/audio/clip_player.rs index 9e8003f..6ee3ba4 100644 --- a/src/audio/clip_player.rs +++ b/src/audio/clip_player.rs @@ -40,6 +40,7 @@ enum ClipCommand { Resume, Seek(Duration), Stop, + SetVolume(f32), } /// Cheap, `Send` command handle for the dedicated playback thread. @@ -51,13 +52,16 @@ pub struct ClipPlayer { impl ClipPlayer { /// Start the playback worker. The system output device is opened lazily on /// first Play, so merely launching PeerSpeak never claims another stream. - pub fn new() -> (Self, SharedClipStatus) { + /// + /// `initial_volume` is the universal gain (`1.0` = unity) applied to every + /// clip, restored from config so the level persists across sessions. + pub fn new(initial_volume: f32) -> (Self, SharedClipStatus) { let (command_tx, command_rx) = mpsc::channel(); let status = Arc::new(Mutex::new(ClipStatus::default())); let worker_status = Arc::clone(&status); std::thread::Builder::new() .name("peerspeak-clip-player".to_string()) - .spawn(move || playback_worker(command_rx, worker_status)) + .spawn(move || playback_worker(command_rx, worker_status, initial_volume)) .expect("failed to spawn clip playback thread"); ( Self { @@ -94,11 +98,24 @@ impl ClipPlayer { pub fn stop(&self) { let _ = self.command_tx.send(ClipCommand::Stop); } + + /// Set the universal playback gain (`1.0` = unity). Applies to the current + /// clip immediately and to every clip played afterwards. + pub fn set_volume(&self, volume: f32) { + let _ = self.command_tx.send(ClipCommand::SetVolume(volume)); + } } -fn playback_worker(command_rx: mpsc::Receiver, status: SharedClipStatus) { +fn playback_worker( + command_rx: mpsc::Receiver, + status: SharedClipStatus, + initial_volume: f32, +) { let mut output: Option = None; let mut player: Option = None; + // Universal gain remembered across clips so a level set on one upload + // carries to the next; reapplied to each freshly connected player. + let mut volume = initial_volume.max(0.0); loop { match command_rx.recv_timeout(Duration::from_millis(100)) { @@ -124,7 +141,9 @@ fn playback_worker(command_rx: mpsc::Receiver, status: SharedClipSt if output.is_none() { match DeviceSinkBuilder::open_default_sink() { Ok(sink) => { - player = Some(Player::connect_new(sink.mixer())); + let new_player = Player::connect_new(sink.mixer()); + new_player.set_volume(volume); + player = Some(new_player); output = Some(sink); } Err(error) => { @@ -177,6 +196,12 @@ fn playback_worker(command_rx: mpsc::Receiver, status: SharedClipSt } reset(&status); } + Ok(ClipCommand::SetVolume(level)) => { + volume = level.max(0.0); + if let Some(player) = player.as_ref() { + player.set_volume(volume); + } + } Err(mpsc::RecvTimeoutError::Disconnected) => break, Err(mpsc::RecvTimeoutError::Timeout) => {} } diff --git a/src/config.rs b/src/config.rs index 20ef286..2080985 100644 --- a/src/config.rs +++ b/src/config.rs @@ -156,6 +156,14 @@ pub struct AppConfig { /// App-internal playback gain applied to the mixed output (1.0 = unity). #[serde(default = "default_volume")] pub output_volume: f32, + /// Universal playback gain for inline chat audio clips (1.0 = unity). One + /// level shared by every uploaded clip so the slider sticks across plays. + #[serde(default = "default_volume")] + pub clip_volume: f32, + /// When true, `clip_volume` governs every clip. When false, each clip keeps + /// its own (in-memory) level and the universal slider is inactive. + #[serde(default = "default_true")] + pub clip_volume_universal: bool, #[serde(default)] pub network_mode: NetworkMode, /// Presence posture for the friends idle listener (W7): invisible / normal / @@ -312,6 +320,8 @@ impl Default for AppConfig { noise_gate_threshold: 0.01, input_volume: 1.0, output_volume: 1.0, + clip_volume: 1.0, + clip_volume_universal: true, network_mode: NetworkMode::default(), presence_mode: crate::presence::PresenceMode::default(), echo_cancellation_enabled: false, @@ -657,23 +667,32 @@ mod tests { let def = AppConfig::default(); assert_eq!(def.input_volume, 1.0); assert_eq!(def.output_volume, 1.0); + assert_eq!(def.clip_volume, 1.0); + assert!(def.clip_volume_universal); // Missing in JSON → unity (serde default). let missing = r#"{"input_device":"","output_device":"","noise_gate_threshold":0.01}"#; let cfg_missing: AppConfig = serde_json::from_str(missing).unwrap(); assert_eq!(cfg_missing.input_volume, 1.0); assert_eq!(cfg_missing.output_volume, 1.0); + assert_eq!(cfg_missing.clip_volume, 1.0); + // Configs predating the toggle default to universal mode. + assert!(cfg_missing.clip_volume_universal); // Explicit non-unity values are preserved across a round-trip. let cfg = AppConfig { input_volume: 1.5, output_volume: 0.25, + clip_volume: 0.7, + clip_volume_universal: false, ..AppConfig::default() }; let round_tripped: AppConfig = serde_json::from_str(&serde_json::to_string(&cfg).unwrap()).unwrap(); assert_eq!(round_tripped.input_volume, 1.5); assert_eq!(round_tripped.output_volume, 0.25); + assert_eq!(round_tripped.clip_volume, 0.7); + assert!(!round_tripped.clip_volume_universal); } #[test]