Compare commits
6
Commits
v0.6.0
..
1c8c37b248
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c8c37b248 | ||
|
|
08792809d6 | ||
|
|
9ff7c7b99c | ||
|
|
3ff0945866 | ||
|
|
618a53027d | ||
|
|
6054f0ecf9 |
@@ -0,0 +1,110 @@
|
||||
<h1>
|
||||
<img src="assets/icons/peerspeak.svg" width="48" align="left" alt="PeerSpeak icon">
|
||||
PeerSpeak
|
||||
</h1>
|
||||
|
||||
Decentralized, peer-to-peer voice chat — full-mesh, NAT-traversing, with **no central server**. Built in Rust on [iroh](https://github.com/n0-computer/iroh) (QUIC), PipeWire audio, the Opus codec, and an [iced](https://github.com/iced-rs/iced) GUI.
|
||||
|
||||
Create a room, share the join ticket, and talk. Everyone connects directly to everyone else; relays are only used to punch through NATs when a direct path isn't available.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Launch screen | In a room |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Settings |
|
||||
|---|
|
||||
|  |
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
**Rooms & sessions**
|
||||
- Create a room → shareable join ticket; join by pasting a ticket.
|
||||
- Full-mesh multi-peer rooms with live presence.
|
||||
- Recent-rooms list to hop back into a room someone's still in.
|
||||
- Remembered nickname and in-call duration timer.
|
||||
|
||||
**Audio**
|
||||
- PipeWire capture/playback, selectable input and output devices, per-app gain.
|
||||
- Opus codec (48 kHz mono, 20 ms frames) with an adaptive jitter buffer + packet-loss concealment.
|
||||
- Noise gate with a draggable threshold on a live mic meter (test your mic off-call too).
|
||||
- Mix-bus soft limiter and opt-in echo cancellation (PipeWire WebRTC AEC + noise suppression).
|
||||
|
||||
**Voice controls**
|
||||
- Self-mute, deafen, and rebindable push-to-talk.
|
||||
- Per-peer volume, local mute, and speaking indicators.
|
||||
|
||||
**Text chat**
|
||||
- In-room text chat over the gossip plane, with clickable links and inline image/audio attachments.
|
||||
- Drag-selectable, copyable messages; right-click context menu on all text fields.
|
||||
|
||||
**Shared music listening**
|
||||
- Build a personal playlist of local audio files with a full transport (play/pause, seek, reorder).
|
||||
- Let others tune in: peers stream your current track, timeline-synced and gapless, sitting under voice at their own volume.
|
||||
|
||||
**Screen share** (via [pixelpass](https://gitbutter.xyz/mollusk/pixelpass))
|
||||
- Share your screen; peers click 👁 Watch to open the stream in mpv (vlc fallback).
|
||||
- Live badges on sharing peers; per-app audio capture.
|
||||
|
||||
**Recording & notifications**
|
||||
- Local call recording (mic + incoming mix → WAV in `~/peerspeak-recordings/`).
|
||||
- Desktop notifications and event chimes with per-event custom sound overrides.
|
||||
|
||||
**UI & networking**
|
||||
- Selectable room layouts (3-Column, Bottom Dock, Drawer) with draggable, persisted dividers.
|
||||
- 10 built-in themes (Catppuccin, Dracula, Nord, Tokyo Night, Gruvbox, Solarized…), all WCAG-AA checked.
|
||||
- Network mode picker (relay-no-discovery default, full n0, or direct-only); retained-address reconnect.
|
||||
- Config, window size/position, and all preferences persisted to `~/.config/peerspeak/`.
|
||||
|
||||
See [`docs/FEATURES.md`](docs/FEATURES.md) for the full inventory and field-test status, and [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for internals.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- **Contacts & invites** — friends list with invite-notification one-click join (design in [`docs/contacts-plan.md`](docs/contacts-plan.md)).
|
||||
- **Spatial audio & per-peer EQ.**
|
||||
- **Soundboard** — play short clips into the call mix.
|
||||
- **Room persistence / invite links** beyond the raw ticket.
|
||||
- **Windows support** — cross-compiles and launches under Wine today; needs a real WASAPI audio pass (see [`docs/WINDOWS.md`](docs/WINDOWS.md)).
|
||||
|
||||
## Building
|
||||
|
||||
PeerSpeak builds with a stable Rust toolchain (edition 2024). Install the system dependencies below, then:
|
||||
|
||||
```sh
|
||||
cargo build --release
|
||||
./target/release/peerspeak
|
||||
```
|
||||
|
||||
### System dependencies
|
||||
|
||||
**Arch Linux**
|
||||
|
||||
```sh
|
||||
sudo pacman -S --needed rust pipewire opus pkgconf git
|
||||
```
|
||||
|
||||
**Debian / Ubuntu**
|
||||
|
||||
```sh
|
||||
sudo apt install build-essential pkg-config clang libclang-dev \
|
||||
libpipewire-0.3-dev libopus-dev libasound2-dev libxcb1-dev
|
||||
```
|
||||
|
||||
Plus a Rust toolchain via [rustup](https://rustup.rs/). `clang`/`libclang` are needed for the PipeWire bindings (bindgen).
|
||||
|
||||
At runtime you need a running **PipeWire** server. Screen sharing additionally requires `pixelpass` on your `PATH`, and `mpv` (or `vlc`) to watch a peer's share.
|
||||
|
||||
### Packaging
|
||||
|
||||
- **Arch:** `cd packaging && makepkg -si` (uses [`packaging/PKGBUILD`](packaging/PKGBUILD)).
|
||||
- **Debian/Ubuntu:** `.deb` is built with [`cargo-deb`](https://github.com/kornelski/cargo-deb) from the `[package.metadata.deb]` block in `Cargo.toml`. Build inside a Debian/Ubuntu environment so the binary links that distro's glibc.
|
||||
- **Windows:** see [`docs/WINDOWS.md`](docs/WINDOWS.md).
|
||||
|
||||
## License
|
||||
|
||||
Private build — not for redistribution. See `Cargo.toml` for details.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 210 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 139 KiB |
@@ -38,6 +38,11 @@ build() {
|
||||
export CARGO_HOME="$srcdir/cargo-home"
|
||||
export RUSTUP_TOOLCHAIN=stable
|
||||
export CARGO_TARGET_DIR=target
|
||||
# Strip the build directory out of paths embedded in the binary (Rust bakes
|
||||
# source paths into panic/backtrace metadata that survives stripping), so the
|
||||
# package doesn't reference $srcdir. One remap covers our sources and the
|
||||
# vendored deps, since CARGO_HOME lives under $srcdir too.
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} --remap-path-prefix=$srcdir=/"
|
||||
cargo build --frozen --release --bin "$_pkgname"
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
; (x86_64-pc-windows-gnu, statically linked -- no extra DLLs needed).
|
||||
|
||||
#define MyAppName "PeerSpeak"
|
||||
#define MyAppVersion "0.5.0"
|
||||
#define MyAppVersion "0.6.0"
|
||||
#define MyAppPublisher "mollusk"
|
||||
#define MyAppExeName "peerspeak.exe"
|
||||
|
||||
|
||||
+142
-25
@@ -401,6 +401,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
|
||||
@@ -645,6 +649,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.
|
||||
@@ -804,6 +811,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();
|
||||
@@ -949,6 +957,7 @@ 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(),
|
||||
@@ -1967,6 +1976,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
|
||||
@@ -2673,7 +2688,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();
|
||||
@@ -3778,30 +3799,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> {
|
||||
@@ -5772,9 +5798,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)
|
||||
@@ -6922,6 +6960,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,
|
||||
|
||||
Reference in New Issue
Block a user