Add inline chat audio player
This commit is contained in:
+179
-2
@@ -2,6 +2,10 @@ use crate::core::{CoreController, messages::{CoreCommand, UiEvent}};
|
||||
use crate::network::PeerState;
|
||||
use crate::notify::{self, Sound};
|
||||
use crate::audio::eq::{EqSettings, EQ_GAIN_DB_MAX, EQ_GAIN_DB_MIN};
|
||||
use crate::audio::clip_player::{
|
||||
ClipPlayer, SharedClipStatus, format_time as format_clip_time, progress as clip_progress,
|
||||
seek_target, status_snapshot,
|
||||
};
|
||||
use crate::audio::{AudioDevice, enumerate_audio_devices};
|
||||
use crate::config::{AppConfig, NetworkMode, RecordingMode, RoomLayout};
|
||||
use crate::hotkeys::{format_binding, HotkeyAction, HotkeyContext, KeyBinding};
|
||||
@@ -283,6 +287,13 @@ pub enum AppMessage {
|
||||
AttachmentFilePicked(Option<(String, Vec<u8>)>),
|
||||
/// Save (downloading first if needed) a received attachment to disk.
|
||||
SaveAttachment(crate::files::AttachmentId),
|
||||
/// Fetch (if needed) and start an inline audio attachment.
|
||||
PlayAudio(crate::files::AttachmentId),
|
||||
PauseAudio,
|
||||
ResumeAudio,
|
||||
SeekAudio(crate::files::AttachmentId, f32),
|
||||
/// Redraw cadence while an inline clip is active.
|
||||
AudioTick,
|
||||
/// Send the current chat input line (Enter or the Send button).
|
||||
ChatSubmit,
|
||||
/// Open a clicked chat link in the system browser (A13).
|
||||
@@ -390,6 +401,15 @@ pub struct AppState {
|
||||
/// Attachment ids the user asked to save before the bytes arrived; when the
|
||||
/// fetch completes a save dialog is opened for them.
|
||||
pending_saves: std::collections::HashSet<crate::files::AttachmentId>,
|
||||
/// Clip ids waiting for the existing attachment fetch path to return bytes.
|
||||
pending_plays: HashSet<crate::files::AttachmentId>,
|
||||
/// Filename-hinted audio whose fetched bytes or decoder validation failed;
|
||||
/// these entries fall back to the normal file chip.
|
||||
invalid_audio: HashSet<crate::files::AttachmentId>,
|
||||
/// Independent system-default-device player for chat clips. It never enters
|
||||
/// the call capture/mixer path.
|
||||
clip_player: ClipPlayer,
|
||||
clip_status: SharedClipStatus,
|
||||
/// Last known window size, tracked so divider clamps stay valid on resize.
|
||||
/// (The divider positions themselves are persisted in `config`.)
|
||||
window_size: Size,
|
||||
@@ -522,6 +542,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();
|
||||
|
||||
Self {
|
||||
// Pre-fill the nickname with the last one used (or "Peer" by default).
|
||||
@@ -552,6 +573,10 @@ impl Default for AppState {
|
||||
attachment_data: HashMap::new(),
|
||||
image_handle_cache: HashMap::new(),
|
||||
pending_saves: std::collections::HashSet::new(),
|
||||
pending_plays: HashSet::new(),
|
||||
invalid_audio: HashSet::new(),
|
||||
clip_player,
|
||||
clip_status,
|
||||
chat_input: String::new(),
|
||||
window_size: Size::new(ww, wh),
|
||||
layout_picker_open: false,
|
||||
@@ -676,10 +701,15 @@ fn initial_window_position(
|
||||
}
|
||||
}
|
||||
|
||||
fn subscription(_state: &AppState) -> Subscription<AppMessage> {
|
||||
fn subscription(state: &AppState) -> Subscription<AppMessage> {
|
||||
let core_sub = Subscription::run(core_subscription).map(AppMessage::UiEventReceived);
|
||||
let event_sub = iced::event::listen().map(AppMessage::EventOccurred);
|
||||
Subscription::batch(vec![core_sub, event_sub])
|
||||
let audio_sub = if status_snapshot(&state.clip_status).playing_id.is_some() {
|
||||
iced::time::every(std::time::Duration::from_millis(250)).map(|_| AppMessage::AudioTick)
|
||||
} else {
|
||||
Subscription::none()
|
||||
};
|
||||
Subscription::batch(vec![core_sub, event_sub, audio_sub])
|
||||
}
|
||||
|
||||
fn shutdown_timeout_task() -> Task<AppMessage> {
|
||||
@@ -951,6 +981,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
notify::play(Sound::SelfJoin, state.config.custom_sound_self_join.as_deref());
|
||||
}
|
||||
UiEvent::RoomLeft => {
|
||||
state.clip_player.stop();
|
||||
state.ticket = "".to_string();
|
||||
state.peers.clear();
|
||||
state.audio_levels.clear();
|
||||
@@ -960,6 +991,11 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
state.recording_started = None;
|
||||
state.chat_messages.clear();
|
||||
state.chat_input.clear();
|
||||
state.attachment_data.clear();
|
||||
state.image_handle_cache.clear();
|
||||
state.pending_saves.clear();
|
||||
state.pending_plays.clear();
|
||||
state.invalid_audio.clear();
|
||||
state.connecting.clear();
|
||||
state.ever_connected.clear();
|
||||
state.status_message = "Ready to connect".to_string();
|
||||
@@ -1056,13 +1092,18 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
);
|
||||
}
|
||||
let needs_save = state.pending_saves.remove(&id);
|
||||
let needs_play = state.pending_plays.remove(&id);
|
||||
state.attachment_data.insert(id, AttachmentState::Ready(data));
|
||||
if needs_save {
|
||||
save_attachment_to_disk(state, id);
|
||||
}
|
||||
if needs_play {
|
||||
play_ready_audio(state, id);
|
||||
}
|
||||
}
|
||||
UiEvent::AttachmentFailed { id, error } => {
|
||||
state.pending_saves.remove(&id);
|
||||
state.pending_plays.remove(&id);
|
||||
state.attachment_data.insert(id, AttachmentState::Failed(error.clone()));
|
||||
state.status_message = format!("Attachment failed: {error}");
|
||||
}
|
||||
@@ -1635,6 +1676,45 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::PlayAudio(id) => {
|
||||
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
||||
play_ready_audio(state, id);
|
||||
} else if let Some((from, att)) = find_attachment_source(state, id) {
|
||||
if let Ok(eid) = from.parse::<EndpointId>() {
|
||||
// Repeated clicks while the transfer is pending must not
|
||||
// launch duplicate fetches.
|
||||
if state.pending_plays.insert(id) {
|
||||
state.status_message = format!("Loading {}…", att.name);
|
||||
let _ = state
|
||||
.controller
|
||||
.send(CoreCommand::FetchAttachment { from: eid, attachment: att });
|
||||
}
|
||||
} else {
|
||||
state.status_message = "Can't play: unknown sender.".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::PauseAudio => state.clip_player.pause(),
|
||||
AppMessage::ResumeAudio => state.clip_player.resume(),
|
||||
AppMessage::SeekAudio(id, fraction) => {
|
||||
let clip = status_snapshot(&state.clip_status);
|
||||
if clip.playing_id == Some(id)
|
||||
&& let Some(total) = clip.total
|
||||
{
|
||||
state.clip_player.seek(seek_target(fraction, total));
|
||||
}
|
||||
}
|
||||
AppMessage::AudioTick => {
|
||||
let clip = status_snapshot(&state.clip_status);
|
||||
if let Some(failure) = clip.failure {
|
||||
if failure.invalid_audio {
|
||||
state.invalid_audio.insert(failure.id);
|
||||
}
|
||||
state.pending_plays.remove(&failure.id);
|
||||
state.status_message = format!("Audio playback failed: {}", failure.error);
|
||||
state.clip_player.stop();
|
||||
}
|
||||
}
|
||||
AppMessage::OpenUrl(url) => {
|
||||
// Defence in depth: only ever hand http(s) URLs to the opener. The
|
||||
// link span's href came from `linkify`, which only emits http/https,
|
||||
@@ -1885,6 +1965,21 @@ 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.
|
||||
fn play_ready_audio(state: &mut AppState, id: crate::files::AttachmentId) {
|
||||
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
|
||||
return;
|
||||
};
|
||||
if crate::files::is_probably_audio(data) {
|
||||
state.invalid_audio.remove(&id);
|
||||
state.clip_player.play(id, data.clone());
|
||||
} else {
|
||||
state.invalid_audio.insert(id);
|
||||
state.status_message = "This attachment is not valid supported audio.".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a ready attachment's bytes to a user-chosen location via a native save
|
||||
/// dialog. The default filename comes from the (already-sanitized) descriptor.
|
||||
/// No-op if the bytes aren't ready. Sync dialog: the brief block is acceptable
|
||||
@@ -3833,6 +3928,7 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.color(color_subtext),
|
||||
);
|
||||
} else {
|
||||
let clip_status = status_snapshot(&state.clip_status);
|
||||
for m in &state.chat_messages {
|
||||
let name_color = if m.mine { color_green } else { color_lavender };
|
||||
// Split the (already-sanitized) message into text + URL spans so
|
||||
@@ -3899,6 +3995,87 @@ fn view(state: &AppState) -> Element<'_, AppMessage> {
|
||||
.color(color_subtext)
|
||||
.into(),
|
||||
}
|
||||
} else if crate::files::looks_like_audio_name(&att.name)
|
||||
&& !state.invalid_audio.contains(&att.id)
|
||||
{
|
||||
let active = clip_status.playing_id == Some(att.id);
|
||||
let loading = state.pending_plays.contains(&att.id)
|
||||
&& !matches!(data, Some(AttachmentState::Ready(_)));
|
||||
let position = if active {
|
||||
clip_status.position
|
||||
} else {
|
||||
std::time::Duration::ZERO
|
||||
};
|
||||
let total = active.then_some(clip_status.total).flatten();
|
||||
let play_button = if loading {
|
||||
button(text("Loading…").size(12))
|
||||
} else if active && clip_status.paused {
|
||||
button(text("Play").size(12)).on_press(AppMessage::ResumeAudio)
|
||||
} else if active {
|
||||
button(text("Pause").size(12)).on_press(AppMessage::PauseAudio)
|
||||
} else {
|
||||
button(text("Play").size(12))
|
||||
.on_press(AppMessage::PlayAudio(att.id))
|
||||
}
|
||||
.style(b_style(
|
||||
color_blue,
|
||||
color_lavender,
|
||||
color_crust,
|
||||
6.0,
|
||||
))
|
||||
.padding(6);
|
||||
let elapsed = format_clip_time(position);
|
||||
let duration = total
|
||||
.map(format_clip_time)
|
||||
.unwrap_or_else(|| "--:--".to_string());
|
||||
column![
|
||||
row![
|
||||
text(format!(
|
||||
"{} ({})",
|
||||
att.name,
|
||||
crate::files::human_size(att.size)
|
||||
))
|
||||
.size(12)
|
||||
.color(color_text),
|
||||
button(text(if matches!(data, Some(AttachmentState::Ready(_))) {
|
||||
"Save"
|
||||
} else {
|
||||
"Download"
|
||||
})
|
||||
.size(12))
|
||||
.on_press(AppMessage::SaveAttachment(att.id))
|
||||
.style(b_style(
|
||||
color_surface,
|
||||
color_overlay,
|
||||
color_text,
|
||||
6.0,
|
||||
))
|
||||
.padding(6),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
row![
|
||||
play_button,
|
||||
slider(
|
||||
0.0..=1.0,
|
||||
if active {
|
||||
clip_progress(position, total)
|
||||
} else {
|
||||
0.0
|
||||
},
|
||||
move |fraction| AppMessage::SeekAudio(att.id, fraction),
|
||||
)
|
||||
.step(0.001)
|
||||
.width(iced::Length::Fixed(180.0)),
|
||||
text(format!("{elapsed} / {duration}"))
|
||||
.size(11)
|
||||
.color(color_subtext),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::alignment::Vertical::Center),
|
||||
]
|
||||
.spacing(4)
|
||||
.into()
|
||||
} else {
|
||||
let ready =
|
||||
matches!(data, Some(AttachmentState::Ready(_)));
|
||||
|
||||
Reference in New Issue
Block a user