Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6054f0ecf9 | ||
|
|
c2e27c3367 | ||
|
|
3c678afaf7 | ||
|
|
8cfb5beff9 | ||
|
|
f21a027e78 |
@@ -2,6 +2,11 @@
|
||||
|
||||
All notable changes to PeerSpeak are documented here.
|
||||
|
||||
## [0.5.1] — 2026-06-27
|
||||
|
||||
### Added
|
||||
- **Volume control for inline chat audio clips.** A master volume slider plus a **Universal volume** toggle now sit in the Chat panel header, and every audio attachment gets its own 🔊 slider on its play row. With Universal volume on (the default), one level applies to all clips and persists across sessions; turn it off to give each clip its own independent level.
|
||||
|
||||
## [0.5.0] — 2026-06-27
|
||||
|
||||
### Added
|
||||
@@ -22,6 +27,7 @@ All notable changes to PeerSpeak are documented here.
|
||||
- Added Debian/Ubuntu `.deb` packaging (cargo-deb metadata); the official `.deb` is now built on **Debian 12 (bookworm)** for wide compatibility.
|
||||
- Arch `PKGBUILD` clones over anonymous HTTPS.
|
||||
|
||||
[0.5.1]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.1
|
||||
[0.5.0]: https://gitbutter.xyz/mollusk/peerspeak/releases/tag/v0.5.0
|
||||
|
||||
Earlier releases: see git tags `v0.4.0`, `v0.3.0`, `v0.2.0`.
|
||||
|
||||
Generated
+1
-1
@@ -4871,7 +4871,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "peerspeak"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "peerspeak"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
edition = "2024"
|
||||
description = "Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
# Application crate, not a crates.io library — refuse `cargo publish` and let
|
||||
|
||||
+232
-27
@@ -385,6 +385,10 @@ pub enum AppMessage {
|
||||
/// the full `(author, id)` key so the correct sender's bytes are fetched and
|
||||
/// saved even if another peer reused the same attachment id (Tier C F-12).
|
||||
SaveAttachment(AttachmentKey),
|
||||
/// Open the click-to-enlarge image lightbox for this attachment (author + id).
|
||||
OpenImageLightbox(AttachmentKey),
|
||||
/// Close the image lightbox overlay.
|
||||
CloseImageLightbox,
|
||||
/// Result of the async save dialog: a status line to show, or None if cancelled.
|
||||
AttachmentSaved(Option<String>),
|
||||
/// Fetch (if needed) and start an inline audio attachment. Carries the full
|
||||
@@ -393,6 +397,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).
|
||||
@@ -569,6 +582,9 @@ pub struct AppState {
|
||||
/// `(author, id)` and bounded. Session-only (cleared on leave); never
|
||||
/// persisted. (Tier C F-02 bound + F-12 author keying.)
|
||||
attachments: AttachmentCache,
|
||||
/// When `Some`, the image lightbox overlay is open showing this attachment
|
||||
/// (click-to-enlarge from the chat panel). Closed = `None`.
|
||||
image_lightbox: Option<AttachmentKey>,
|
||||
/// Attachments the user asked to save before the bytes arrived; when the
|
||||
/// fetch completes a save dialog is opened for them. Keyed by `(author, id)`
|
||||
/// so a same-id attachment from a different sender can't trigger the save.
|
||||
@@ -582,6 +598,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<crate::files::AttachmentId, f32>,
|
||||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||
/// (The divider positions themselves are persisted in `config`.)
|
||||
window_size: Size,
|
||||
@@ -685,6 +705,7 @@ impl AppState {
|
||||
self.chat_messages.clear();
|
||||
self.chat_input.clear();
|
||||
self.attachments.clear();
|
||||
self.image_lightbox = None;
|
||||
self.pending_saves.clear();
|
||||
self.pending_plays.clear();
|
||||
self.invalid_audio.clear();
|
||||
@@ -781,7 +802,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).
|
||||
@@ -814,11 +835,13 @@ impl Default for AppState {
|
||||
recording_started: None,
|
||||
chat_messages: Vec::new(),
|
||||
attachments: AttachmentCache::new(ATTACHMENT_CACHE_CAP),
|
||||
image_lightbox: None,
|
||||
pending_saves: HashSet::new(),
|
||||
pending_plays: HashSet::new(),
|
||||
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,
|
||||
@@ -1762,6 +1785,12 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
AppMessage::CloseRegenerateIdentityConfirm => {
|
||||
state.regenerate_identity_confirm_open = false;
|
||||
}
|
||||
AppMessage::OpenImageLightbox(key) => {
|
||||
state.image_lightbox = Some(key);
|
||||
}
|
||||
AppMessage::CloseImageLightbox => {
|
||||
state.image_lightbox = None;
|
||||
}
|
||||
AppMessage::ConfirmRegenerateIdentity => {
|
||||
state.regenerate_identity_confirm_open = false;
|
||||
// The core mints + persists the new key and replies with a fresh
|
||||
@@ -2200,6 +2229,38 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
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 {
|
||||
@@ -2250,7 +2311,13 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
.send(CoreCommand::SetMicMonitor { enabled, input_device });
|
||||
}
|
||||
AppMessage::EventOccurred(Event::Keyboard(keyboard::Event::KeyPressed { key, .. })) => {
|
||||
if let Some(action) = state.hotkey_capture.take() {
|
||||
if state.image_lightbox.is_some()
|
||||
&& key == keyboard::Key::Named(keyboard::key::Named::Escape)
|
||||
{
|
||||
// Esc closes the image lightbox (takes priority over hotkeys so a
|
||||
// user-bound Escape can't fire while the overlay is up).
|
||||
state.image_lightbox = None;
|
||||
} else if let Some(action) = state.hotkey_capture.take() {
|
||||
if let Some(binding) = KeyBinding::from_key(&key) {
|
||||
state.config.hotkeys.set_binding(action, Some(binding));
|
||||
state.config.save();
|
||||
@@ -2512,6 +2579,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 +2598,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();
|
||||
@@ -2992,30 +3072,35 @@ fn friends_panel(state: &AppState) -> Element<'_, AppMessage> {
|
||||
/// through the gaps between panels. This is the registered top-level view.
|
||||
fn view_with_background(state: &AppState) -> Element<'_, AppMessage> {
|
||||
let content = view(state);
|
||||
let Some(bytes) = state.background_image.clone() else {
|
||||
return content;
|
||||
};
|
||||
let pal = state.config.theme.palette();
|
||||
let dim = state.config.background_dim;
|
||||
let image_layer = iced::widget::image(cached_image_handle(bytes))
|
||||
.content_fit(iced::ContentFit::Cover)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill);
|
||||
let scrim = container(
|
||||
iced::widget::Space::new()
|
||||
// Compose the base UI (optionally over a background image), then route the
|
||||
// whole thing through the lightbox overlay so a click-to-enlarge image paints
|
||||
// on top of everything (background + content).
|
||||
let composed: Element<'_, AppMessage> = if let Some(bytes) = state.background_image.clone() {
|
||||
let pal = state.config.theme.palette();
|
||||
let dim = state.config.background_dim;
|
||||
let image_layer = iced::widget::image(cached_image_handle(bytes))
|
||||
.content_fit(iced::ContentFit::Cover)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill),
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))),
|
||||
..Default::default()
|
||||
});
|
||||
iced::widget::stack![image_layer, scrim, content]
|
||||
.height(iced::Length::Fill);
|
||||
let scrim = container(
|
||||
iced::widget::Space::new()
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill),
|
||||
)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.into()
|
||||
.style(move |_: &Theme| container::Style {
|
||||
background: Some(Background::Color(crate::background::scrim_color(pal.base, dim))),
|
||||
..Default::default()
|
||||
});
|
||||
iced::widget::stack![image_layer, scrim, content]
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.into()
|
||||
} else {
|
||||
content
|
||||
};
|
||||
with_image_lightbox(composed, state)
|
||||
}
|
||||
|
||||
fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
@@ -4722,9 +4807,21 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.into()
|
||||
} else if att.kind == crate::files::AttachmentKind::Image {
|
||||
match key.as_ref().and_then(|k| state.attachments.handle(k)) {
|
||||
Some(handle) => iced::widget::image(handle.clone())
|
||||
.width(iced::Length::Fixed(260.0))
|
||||
.into(),
|
||||
Some(handle) => {
|
||||
// Click an inline image to open it enlarged in the
|
||||
// lightbox overlay (pointer cursor signals it's
|
||||
// interactive). Only wired when we have a valid
|
||||
// (author, id) key to look the handle back up.
|
||||
let img = iced::widget::image(handle.clone())
|
||||
.width(iced::Length::Fixed(260.0));
|
||||
match key {
|
||||
Some(k) => mouse_area(img)
|
||||
.interaction(mouse::Interaction::Pointer)
|
||||
.on_press(AppMessage::OpenImageLightbox(k))
|
||||
.into(),
|
||||
None => img.into(),
|
||||
}
|
||||
}
|
||||
None => text(format!("🖼 {} — loading…", att.name))
|
||||
.size(12)
|
||||
.color(color_subtext)
|
||||
@@ -4805,6 +4902,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 +4976,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,
|
||||
]
|
||||
@@ -5842,6 +5968,85 @@ fn with_regenerate_confirm<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Overlay the click-to-enlarge image lightbox when `state.image_lightbox` is set
|
||||
/// (W22). Clicking the dimmed backdrop, the enlarged image, or the top-right ✕
|
||||
/// button closes it (`CloseImageLightbox`). Modeled on `with_regenerate_confirm`.
|
||||
fn with_image_lightbox<'a>(
|
||||
base: Element<'a, AppMessage>,
|
||||
state: &'a AppState,
|
||||
) -> Element<'a, AppMessage> {
|
||||
let Some(key) = state.image_lightbox.as_ref() else {
|
||||
return base;
|
||||
};
|
||||
// If the cached handle was evicted (Tier C F-02 bound), there's nothing to
|
||||
// show — fall back to the base UI rather than an empty overlay.
|
||||
let Some(handle) = state.attachments.handle(key) else {
|
||||
return base;
|
||||
};
|
||||
let pal = state.config.theme.palette();
|
||||
let crust = pal.crust;
|
||||
let mantle = pal.mantle;
|
||||
let surface = pal.surface;
|
||||
let text_c = pal.text;
|
||||
|
||||
// Full-window dim that closes on click (click-outside-to-close).
|
||||
let backdrop = mouse_area(
|
||||
container(horizontal_space())
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.style(move |_t: &Theme| container::Style {
|
||||
background: Some(Background::Color(Color { a: 0.80, ..crust })),
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.on_press(AppMessage::CloseImageLightbox);
|
||||
|
||||
// Contain-fit so the image scales down to the window without cropping or
|
||||
// overflowing, bounded to most of the viewport by the surrounding padding.
|
||||
let big = iced::widget::image(handle.clone())
|
||||
.content_fit(iced::ContentFit::Contain)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill);
|
||||
|
||||
// ✕ close button, pinned top-right (subtle styling matching the dialog's
|
||||
// Cancel button).
|
||||
let close_btn = button(text("✕").size(18))
|
||||
.on_press(AppMessage::CloseImageLightbox)
|
||||
.style(move |_t: &Theme, status: button::Status| button::Style {
|
||||
background: Some(Background::Color(match status {
|
||||
button::Status::Hovered => surface,
|
||||
_ => mantle,
|
||||
})),
|
||||
text_color: text_c,
|
||||
border: Border { color: surface, width: 1.0, radius: 6.0.into() },
|
||||
..Default::default()
|
||||
})
|
||||
.padding(8);
|
||||
|
||||
// The image sits in its own layer above the backdrop, so clicking the picture
|
||||
// wouldn't reach the backdrop — wrap it so the image itself also closes.
|
||||
let image_area = mouse_area(
|
||||
container(big)
|
||||
.center_x(iced::Length::Fill)
|
||||
.center_y(iced::Length::Fill)
|
||||
.padding(40),
|
||||
)
|
||||
.on_press(AppMessage::CloseImageLightbox);
|
||||
|
||||
stack![
|
||||
base,
|
||||
backdrop,
|
||||
image_area,
|
||||
container(close_btn)
|
||||
.align_x(iced::alignment::Horizontal::Right)
|
||||
.align_y(iced::alignment::Vertical::Top)
|
||||
.width(iced::Length::Fill)
|
||||
.height(iced::Length::Fill)
|
||||
.padding(16),
|
||||
]
|
||||
.into()
|
||||
}
|
||||
|
||||
/// A small two-pane glyph for the square layout-picker button in the top bar.
|
||||
struct LayoutIcon {
|
||||
fg: Color,
|
||||
|
||||
@@ -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<ClipCommand>, status: SharedClipStatus) {
|
||||
fn playback_worker(
|
||||
command_rx: mpsc::Receiver<ClipCommand>,
|
||||
status: SharedClipStatus,
|
||||
initial_volume: f32,
|
||||
) {
|
||||
let mut output: Option<MixerDeviceSink> = None;
|
||||
let mut player: Option<Player> = 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<ClipCommand>, 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<ClipCommand>, 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) => {}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user