Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4f2f2127 | ||
|
|
b6eac330ca | ||
|
|
7fb1c96ca9 | ||
|
|
80a5b73e39 | ||
|
|
adf7d1c0e2 | ||
|
|
bcb597a0ea | ||
|
|
79b24fd567 | ||
|
|
efadc228eb |
@@ -117,6 +117,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3"
|
||||
dependencies = [
|
||||
"alsa-sys",
|
||||
"bitflags 2.11.1",
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alsa-sys"
|
||||
version = "0.3.1"
|
||||
@@ -1041,6 +1053,20 @@ dependencies = [
|
||||
"coreaudio-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-rs"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coreaudio-sys"
|
||||
version = "0.2.18"
|
||||
@@ -1080,14 +1106,14 @@ version = "0.15.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779"
|
||||
dependencies = [
|
||||
"alsa",
|
||||
"alsa 0.9.1",
|
||||
"core-foundation-sys",
|
||||
"coreaudio-rs",
|
||||
"coreaudio-rs 0.11.3",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2",
|
||||
"mach2 0.4.3",
|
||||
"ndk 0.8.0",
|
||||
"ndk-context",
|
||||
"oboe",
|
||||
@@ -1097,6 +1123,36 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpal"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb"
|
||||
dependencies = [
|
||||
"alsa 0.10.0",
|
||||
"coreaudio-rs 0.13.0",
|
||||
"dasp_sample",
|
||||
"jni 0.21.1",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"mach2 0.5.0",
|
||||
"ndk 0.9.0",
|
||||
"ndk-context",
|
||||
"num-derive",
|
||||
"num-traits",
|
||||
"objc2 0.6.4",
|
||||
"objc2-audio-toolbox",
|
||||
"objc2-avf-audio",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -1564,6 +1620,15 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@@ -1699,6 +1764,12 @@ dependencies = [
|
||||
"zune-inflate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "extended"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -2557,6 +2628,7 @@ dependencies = [
|
||||
"iced_core",
|
||||
"log",
|
||||
"rustc-hash 2.1.2",
|
||||
"tokio",
|
||||
"wasm-bindgen-futures",
|
||||
"wasmtimer",
|
||||
]
|
||||
@@ -3565,6 +3637,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mach2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "malloc_buf"
|
||||
version = "0.0.6"
|
||||
@@ -4252,6 +4333,31 @@ dependencies = [
|
||||
"objc2-quartz-core 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-audio-toolbox"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-avf-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be"
|
||||
dependencies = [
|
||||
"objc2 0.6.4",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-cloud-kit"
|
||||
version = "0.2.2"
|
||||
@@ -4287,6 +4393,29 @@ dependencies = [
|
||||
"objc2-foundation 0.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2"
|
||||
dependencies = [
|
||||
"dispatch2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-audio-types",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-audio-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-data"
|
||||
version = "0.2.2"
|
||||
@@ -4748,7 +4877,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"bytes",
|
||||
"cpal",
|
||||
"cpal 0.15.3",
|
||||
"dirs",
|
||||
"iced",
|
||||
"image",
|
||||
@@ -4759,6 +4888,7 @@ dependencies = [
|
||||
"rand 0.10.1",
|
||||
"rfd",
|
||||
"ringbuf",
|
||||
"rodio",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
@@ -5198,6 +5328,16 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
@@ -5487,12 +5627,34 @@ dependencies = [
|
||||
"portable-atomic-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
|
||||
dependencies = [
|
||||
"cpal 0.17.1",
|
||||
"dasp_sample",
|
||||
"num-rational",
|
||||
"rand 0.10.1",
|
||||
"rand_distr",
|
||||
"rtrb",
|
||||
"symphonia",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "roxmltree"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
|
||||
|
||||
[[package]]
|
||||
name = "rtrb"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ade083ccbb4bf536df69d1f6432cc23deb7acccff86b183f3923a6fd56a1153"
|
||||
|
||||
[[package]]
|
||||
name = "rustc-hash"
|
||||
version = "1.1.0"
|
||||
@@ -6156,6 +6318,153 @@ dependencies = [
|
||||
"zeno",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"symphonia-bundle-flac",
|
||||
"symphonia-bundle-mp3",
|
||||
"symphonia-codec-aac",
|
||||
"symphonia-codec-pcm",
|
||||
"symphonia-codec-vorbis",
|
||||
"symphonia-core",
|
||||
"symphonia-format-isomp4",
|
||||
"symphonia-format-ogg",
|
||||
"symphonia-format-riff",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-flac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-bundle-mp3"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-aac"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-pcm"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-codec-vorbis"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-core"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bitflags 1.3.2",
|
||||
"bytemuck",
|
||||
"lazy_static",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-isomp4"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-ogg"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
|
||||
dependencies = [
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
"symphonia-utils-xiph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-format-riff"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
|
||||
dependencies = [
|
||||
"extended",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-metadata"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"symphonia-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "symphonia-utils-xiph"
|
||||
version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
|
||||
dependencies = [
|
||||
"symphonia-core",
|
||||
"symphonia-metadata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.117"
|
||||
|
||||
@@ -28,7 +28,7 @@ async-trait = "0.1.89"
|
||||
base64 = "0.22.1"
|
||||
bytes = "1.11.1"
|
||||
dirs = "6.0.0"
|
||||
iced = { version = "0.14.0", features = ["canvas", "image"] }
|
||||
iced = { version = "0.14.0", features = ["canvas", "image", "tokio"] }
|
||||
# W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep
|
||||
# the codec surface small). The matching native file picker (`rfd`) is platform-
|
||||
# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows).
|
||||
@@ -38,6 +38,7 @@ iroh-gossip = "0.99.0"
|
||||
opus = "0.3.1"
|
||||
rand = "0.10.1"
|
||||
ringbuf = "0.5.0"
|
||||
rodio = "0.22.2"
|
||||
serde = { version = "1.0.228", features = ["derive"] }
|
||||
serde_json = "1.0.150"
|
||||
thiserror = "2.0.18"
|
||||
|
||||
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 606 B After Width: | Height: | Size: 843 B |
|
Before Width: | Height: | Size: 994 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,41 +1,66 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- PeerSpeak app icon: in-app mic glyph + P2P mesh nodes, Catppuccin Mocha. -->
|
||||
<title>PeerSpeak</title>
|
||||
<desc>Two luminous voices meet directly to form a flowing S.</desc>
|
||||
<defs>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1e1e2e"/>
|
||||
<stop offset="1" stop-color="#181825"/>
|
||||
<linearGradient id="tile" x1="32" y1="20" x2="225" y2="239" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#101d42"/>
|
||||
<stop offset="0.5" stop-color="#071225"/>
|
||||
<stop offset="1" stop-color="#160b31"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="voice" x1="45" y1="76" x2="214" y2="184" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#35efff"/>
|
||||
<stop offset="0.42" stop-color="#2583ff"/>
|
||||
<stop offset="0.68" stop-color="#8a42ff"/>
|
||||
<stop offset="1" stop-color="#ff3cdd"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="edge" x1="30" y1="31" x2="225" y2="231" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#2fe9ff" stop-opacity="0.7"/>
|
||||
<stop offset="0.48" stop-color="#386dff" stop-opacity="0.18"/>
|
||||
<stop offset="1" stop-color="#eb42ff" stop-opacity="0.65"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="core">
|
||||
<stop stop-color="#ffffff"/>
|
||||
<stop offset="0.28" stop-color="#baf7ff"/>
|
||||
<stop offset="0.62" stop-color="#7b67ff" stop-opacity="0.65"/>
|
||||
<stop offset="1" stop-color="#7b67ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<filter id="shadow" x="-35%" y="-35%" width="170%" height="170%">
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
</filter>
|
||||
<filter id="soft-shadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||
<feDropShadow dx="0" dy="7" stdDeviation="7" flood-color="#000611" flood-opacity="0.8"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Rounded-square tile -->
|
||||
<rect x="20" y="20" width="216" height="216" rx="48" fill="url(#tile)"
|
||||
stroke="#313244" stroke-width="3"/>
|
||||
<!-- A dark stage makes the cyan/violet conversation mark legible at taskbar size. -->
|
||||
<rect x="8" y="8" width="240" height="240" rx="55" fill="url(#tile)"/>
|
||||
<rect x="9.5" y="9.5" width="237" height="237" rx="53.5" fill="none" stroke="url(#edge)" stroke-width="3"/>
|
||||
|
||||
<!-- P2P mesh: edges (under nodes + mic) -->
|
||||
<g stroke="#45475a" stroke-width="6" stroke-linecap="round" fill="none">
|
||||
<line x1="74" y1="74" x2="128" y2="128"/>
|
||||
<line x1="182" y1="74" x2="128" y2="128"/>
|
||||
<line x1="74" y1="182" x2="128" y2="128"/>
|
||||
<line x1="182" y1="182" x2="128" y2="128"/>
|
||||
<line x1="74" y1="74" x2="182" y2="74"/>
|
||||
<line x1="74" y1="182" x2="182" y2="182"/>
|
||||
</g>
|
||||
<!-- Broad color glow, kept behind the silhouette. -->
|
||||
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||
fill="none" stroke="url(#voice)" stroke-width="25" stroke-linecap="round"
|
||||
opacity="0.5" filter="url(#shadow)"/>
|
||||
|
||||
<!-- P2P mesh: peer nodes -->
|
||||
<g fill="#b4befe">
|
||||
<circle cx="74" cy="74" r="11"/>
|
||||
<circle cx="182" cy="74" r="11"/>
|
||||
<circle cx="74" cy="182" r="11"/>
|
||||
<circle cx="182" cy="182" r="11"/>
|
||||
</g>
|
||||
<!-- The two waveform halves are equal peers and meet at one bright point. -->
|
||||
<path d="M36 128 C50 128 48 101 60 101 C72 101 68 153 81 153 C94 153 90 112 104 112 C115 112 116 128 128 128"
|
||||
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M128 128 C140 128 141 144 152 144 C166 144 162 103 175 103 C188 103 184 155 196 155 C208 155 206 128 220 128"
|
||||
fill="none" stroke="url(#voice)" stroke-width="17" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
|
||||
<!-- Microphone (hero) — same geometry as the in-app Mic icon, scaled 6.4x -->
|
||||
<g fill="none" stroke="#89b4fa" stroke-width="13"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="108.8" y="68.8" width="38.4" height="70.4" rx="19.2"/>
|
||||
<path d="M 169.6 123.2 A 41.6 41.6 0 0 0 86.4 123.2"/>
|
||||
<line x1="128" y1="164.8" x2="128" y2="187.2"/>
|
||||
<line x1="105.6" y1="187.2" x2="150.4" y2="187.2"/>
|
||||
</g>
|
||||
<!-- A single flowing connection turns the conversation into PeerSpeak's S-mark. -->
|
||||
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||
fill="none" stroke="#050b1b" stroke-opacity="0.72" stroke-width="33"
|
||||
stroke-linecap="round" stroke-linejoin="round" filter="url(#soft-shadow)"/>
|
||||
<path d="M160 66 C142 52 108 57 101 78 C94 98 113 111 128 119 C148 130 163 141 157 164 C151 188 117 199 94 184"
|
||||
fill="none" stroke="url(#voice)" stroke-width="25"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M157 65 C139 56 113 62 108 79" fill="none" stroke="#bdf9ff"
|
||||
stroke-opacity="0.68" stroke-width="4" stroke-linecap="round"/>
|
||||
<path d="M153 166 C146 184 118 192 98 181" fill="none" stroke="#f4a8ff"
|
||||
stroke-opacity="0.52" stroke-width="4" stroke-linecap="round"/>
|
||||
|
||||
<!-- The direct connection is the brightest and simplest detail. -->
|
||||
<circle cx="128" cy="128" r="30" fill="url(#core)" opacity="0.78" filter="url(#shadow)"/>
|
||||
<circle cx="128" cy="128" r="6.5" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 3.9 KiB |
@@ -1,7 +1,7 @@
|
||||
# Maintainer: mollusk <jitty+lc1iz0dc@protonmail.com>
|
||||
pkgname=peerspeak-git
|
||||
_pkgname=peerspeak
|
||||
pkgver=0.2.0.r218.gcbba4b6
|
||||
pkgver=0.3.0.r229.g7fb1c96
|
||||
pkgrel=1
|
||||
pkgdesc="Decentralized peer-to-peer voice chat (Rust/iroh/PipeWire/Opus/iced)"
|
||||
arch=('x86_64')
|
||||
|
||||
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
@@ -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,15 @@ pub enum AppMessage {
|
||||
AttachmentFilePicked(Option<(String, Vec<u8>)>),
|
||||
/// Save (downloading first if needed) a received attachment to disk.
|
||||
SaveAttachment(crate::files::AttachmentId),
|
||||
/// 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.
|
||||
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 +403,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 +544,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 +575,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 +703,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 +983,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 +993,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 +1094,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_play {
|
||||
play_ready_audio(state, id);
|
||||
}
|
||||
if needs_save {
|
||||
save_attachment_to_disk(state, id);
|
||||
return save_attachment_task(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}");
|
||||
}
|
||||
@@ -1622,7 +1665,7 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
// If we already have the bytes, save now; otherwise fetch from the
|
||||
// sender and save when AttachmentReady arrives (pending_saves).
|
||||
if matches!(state.attachment_data.get(&id), Some(AttachmentState::Ready(_))) {
|
||||
save_attachment_to_disk(state, id);
|
||||
return save_attachment_task(state, id);
|
||||
} else if let Some((from, att)) = find_attachment_source(state, id) {
|
||||
if let Ok(eid) = from.parse::<EndpointId>() {
|
||||
state.pending_saves.insert(id);
|
||||
@@ -1635,6 +1678,50 @@ fn update(state: &mut AppState, message: AppMessage) -> Task<AppMessage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
AppMessage::AttachmentSaved(msg) => {
|
||||
if let Some(m) = msg {
|
||||
state.status_message = m;
|
||||
}
|
||||
}
|
||||
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,14 +1972,36 @@ fn find_attachment_source(
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// for a deliberate save action.
|
||||
fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId) {
|
||||
/// 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.
|
||||
/// Build a Task that opens the native save dialog off the UI thread and writes
|
||||
/// the (already-fetched) attachment bytes to the chosen path. No-op task if the
|
||||
/// bytes aren't ready.
|
||||
///
|
||||
/// MUST be async (`rfd::AsyncFileDialog`): the blocking `rfd::FileDialog` spins
|
||||
/// its own GTK loop, and invoking it from inside iced's running event loop with
|
||||
/// the Linux `xdg-desktop-portal`/GTK backend wedges the dialog — Save/Cancel
|
||||
/// stop responding. The file *picker* paths already use the async variant; this
|
||||
/// is the one save path that must match.
|
||||
fn save_attachment_task(state: &AppState, id: crate::files::AttachmentId) -> Task<AppMessage> {
|
||||
let Some(AttachmentState::Ready(data)) = state.attachment_data.get(&id) else {
|
||||
return Task::none();
|
||||
};
|
||||
let data = data.clone();
|
||||
let default_name = state
|
||||
.chat_messages
|
||||
@@ -1904,16 +2013,23 @@ fn save_attachment_to_disk(state: &mut AppState, id: crate::files::AttachmentId)
|
||||
.map(|a| a.name.clone())
|
||||
})
|
||||
.unwrap_or_else(|| "download".to_string());
|
||||
if let Some(path) = rfd::FileDialog::new()
|
||||
.set_file_name(default_name)
|
||||
.set_title("Save attachment")
|
||||
.save_file()
|
||||
{
|
||||
match std::fs::write(&path, &data) {
|
||||
Ok(()) => state.status_message = format!("Saved {}", path.display()),
|
||||
Err(e) => state.status_message = format!("Save failed: {e}"),
|
||||
}
|
||||
}
|
||||
Task::perform(
|
||||
async move {
|
||||
let handle = rfd::AsyncFileDialog::new()
|
||||
.set_file_name(default_name)
|
||||
.set_title("Save attachment")
|
||||
.save_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => match std::fs::write(h.path(), &data) {
|
||||
Ok(()) => Some(format!("Saved {}", h.path().display())),
|
||||
Err(e) => Some(format!("Save failed: {e}")),
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
},
|
||||
AppMessage::AttachmentSaved,
|
||||
)
|
||||
}
|
||||
|
||||
fn horizontal_space() -> iced::widget::Space {
|
||||
@@ -3833,6 +3949,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 +4016,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(_)));
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Independent playback engine for inline chat audio attachments.
|
||||
//!
|
||||
//! The rodio device sink stays on a dedicated OS thread and never enters iced
|
||||
//! state or the call-audio pipeline. The GUI sends small commands and reads a
|
||||
//! shared status snapshot at its redraw cadence.
|
||||
|
||||
use crate::files::AttachmentId;
|
||||
use rodio::{Decoder, DeviceSinkBuilder, MixerDeviceSink, Player, Source, decoder::DecoderError};
|
||||
use std::io::Cursor;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
/// State published by the playback thread for the GUI.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ClipStatus {
|
||||
pub playing_id: Option<AttachmentId>,
|
||||
pub position: Duration,
|
||||
pub total: Option<Duration>,
|
||||
pub paused: bool,
|
||||
/// Set when output initialization or decoding rejects the requested clip.
|
||||
/// The app consumes this as a signal to fall back to the normal file chip.
|
||||
pub failure: Option<ClipFailure>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClipFailure {
|
||||
pub id: AttachmentId,
|
||||
pub error: String,
|
||||
/// Decoder rejection means the filename hint should fall back to a file
|
||||
/// chip. Output-device failures remain retryable as audio.
|
||||
pub invalid_audio: bool,
|
||||
}
|
||||
|
||||
pub type SharedClipStatus = Arc<Mutex<ClipStatus>>;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ClipCommand {
|
||||
Play(AttachmentId, Vec<u8>),
|
||||
Pause,
|
||||
Resume,
|
||||
Seek(Duration),
|
||||
Stop,
|
||||
}
|
||||
|
||||
/// Cheap, `Send` command handle for the dedicated playback thread.
|
||||
pub struct ClipPlayer {
|
||||
command_tx: mpsc::Sender<ClipCommand>,
|
||||
status: SharedClipStatus,
|
||||
}
|
||||
|
||||
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) {
|
||||
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))
|
||||
.expect("failed to spawn clip playback thread");
|
||||
(
|
||||
Self {
|
||||
command_tx,
|
||||
status: Arc::clone(&status),
|
||||
},
|
||||
status,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn play(&self, id: AttachmentId, bytes: Vec<u8>) {
|
||||
update_status(&self.status, |status| {
|
||||
status.playing_id = Some(id);
|
||||
status.position = Duration::ZERO;
|
||||
status.total = None;
|
||||
status.paused = false;
|
||||
status.failure = None;
|
||||
});
|
||||
let _ = self.command_tx.send(ClipCommand::Play(id, bytes));
|
||||
}
|
||||
|
||||
pub fn pause(&self) {
|
||||
let _ = self.command_tx.send(ClipCommand::Pause);
|
||||
}
|
||||
|
||||
pub fn resume(&self) {
|
||||
let _ = self.command_tx.send(ClipCommand::Resume);
|
||||
}
|
||||
|
||||
pub fn seek(&self, position: Duration) {
|
||||
let _ = self.command_tx.send(ClipCommand::Seek(position));
|
||||
}
|
||||
|
||||
pub fn stop(&self) {
|
||||
let _ = self.command_tx.send(ClipCommand::Stop);
|
||||
}
|
||||
}
|
||||
|
||||
fn playback_worker(command_rx: mpsc::Receiver<ClipCommand>, status: SharedClipStatus) {
|
||||
let mut output: Option<MixerDeviceSink> = None;
|
||||
let mut player: Option<Player> = None;
|
||||
|
||||
loop {
|
||||
match command_rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ClipCommand::Play(id, bytes)) => {
|
||||
// In-memory readers do not expose file metadata to rodio. Pass
|
||||
// the known attachment length explicitly so formats without a
|
||||
// duration in their headers (notably MP3 and Vorbis) can derive
|
||||
// a total duration and support reliable seeking.
|
||||
let source = match decode_clip(bytes) {
|
||||
Ok(source) => source,
|
||||
Err(error) => {
|
||||
fail(
|
||||
&status,
|
||||
id,
|
||||
format!("unsupported or invalid audio: {error}"),
|
||||
true,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let total = source.total_duration();
|
||||
|
||||
if output.is_none() {
|
||||
match DeviceSinkBuilder::open_default_sink() {
|
||||
Ok(sink) => {
|
||||
player = Some(Player::connect_new(sink.mixer()));
|
||||
output = Some(sink);
|
||||
}
|
||||
Err(error) => {
|
||||
fail(
|
||||
&status,
|
||||
id,
|
||||
format!("audio output unavailable: {error}"),
|
||||
false,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.clear();
|
||||
player.append(source);
|
||||
player.play();
|
||||
update_status(&status, |s| {
|
||||
s.playing_id = Some(id);
|
||||
s.position = Duration::ZERO;
|
||||
s.total = total;
|
||||
s.paused = false;
|
||||
s.failure = None;
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(ClipCommand::Pause) => {
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.pause();
|
||||
update_status(&status, |s| s.paused = true);
|
||||
}
|
||||
}
|
||||
Ok(ClipCommand::Resume) => {
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.play();
|
||||
update_status(&status, |s| s.paused = false);
|
||||
}
|
||||
}
|
||||
Ok(ClipCommand::Seek(position)) => {
|
||||
if let Some(player) = player.as_ref()
|
||||
&& player.try_seek(position).is_ok()
|
||||
{
|
||||
update_status(&status, |s| s.position = position);
|
||||
}
|
||||
}
|
||||
Ok(ClipCommand::Stop) => {
|
||||
if let Some(player) = player.as_ref() {
|
||||
player.clear();
|
||||
}
|
||||
reset(&status);
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
}
|
||||
|
||||
if let Some(player) = player.as_ref() {
|
||||
let (active, failed) = status
|
||||
.lock()
|
||||
.map(|s| (s.playing_id.is_some(), s.failure.is_some()))
|
||||
.unwrap_or_default();
|
||||
if active && !failed && player.empty() {
|
||||
reset(&status);
|
||||
} else if active && !failed {
|
||||
update_status(&status, |s| {
|
||||
s.position = player.get_pos();
|
||||
s.paused = player.is_paused();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_clip(bytes: Vec<u8>) -> Result<Decoder<Cursor<Vec<u8>>>, DecoderError> {
|
||||
let byte_len = bytes.len() as u64;
|
||||
Decoder::builder()
|
||||
.with_data(Cursor::new(bytes))
|
||||
.with_byte_len(byte_len)
|
||||
.build()
|
||||
}
|
||||
|
||||
fn fail(status: &SharedClipStatus, id: AttachmentId, error: String, invalid_audio: bool) {
|
||||
crate::log_msg(&format!("Inline audio playback failed: {error}"));
|
||||
update_status(status, |s| {
|
||||
// Keep the id active until the GUI observes the failure on its next
|
||||
// tick. This guarantees the active-only timer cannot disappear in the
|
||||
// small window between sending Play and decoder/output failure.
|
||||
s.playing_id = Some(id);
|
||||
s.position = Duration::ZERO;
|
||||
s.total = None;
|
||||
s.paused = false;
|
||||
s.failure = Some(ClipFailure {
|
||||
id,
|
||||
error,
|
||||
invalid_audio,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn reset(status: &SharedClipStatus) {
|
||||
update_status(status, |s| *s = ClipStatus::default());
|
||||
}
|
||||
|
||||
fn update_status(status: &SharedClipStatus, update: impl FnOnce(&mut ClipStatus)) {
|
||||
if let Ok(mut status) = status.lock() {
|
||||
update(&mut status);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_snapshot(status: &SharedClipStatus) -> ClipStatus {
|
||||
status.lock().map(|s| s.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Format clip time as `mm:ss` (hours are folded into minutes).
|
||||
pub fn format_time(duration: Duration) -> String {
|
||||
let seconds = duration.as_secs();
|
||||
format!("{}:{:02}", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
/// Playback progress in `0.0..=1.0`; unknown and zero durations report zero.
|
||||
pub fn progress(position: Duration, total: Option<Duration>) -> f32 {
|
||||
let Some(total) = total.filter(|duration| !duration.is_zero()) else {
|
||||
return 0.0;
|
||||
};
|
||||
(position.as_secs_f64() / total.as_secs_f64()).clamp(0.0, 1.0) as f32
|
||||
}
|
||||
|
||||
/// Convert a slider fraction into a clamped position within a clip.
|
||||
pub fn seek_target(fraction: f32, total: Duration) -> Duration {
|
||||
total.mul_f64(f64::from(fraction.clamp(0.0, 1.0)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::Engine;
|
||||
|
||||
#[test]
|
||||
fn in_memory_mp3_reports_duration() {
|
||||
// One headerless constant-bitrate MP3 frame repeated to model files
|
||||
// that do not carry an Xing/VBR duration header.
|
||||
let frame = base64::engine::general_purpose::STANDARD
|
||||
.decode("//sQxAAABIQVWVRggDCqCKiDNlAAAAGgS4BgAmTT2AQAABCxOD5d7gQOfqBAEHS4Ph/EAIRI7//0A0KBNpABgMRIDCSI04PcIFdF0PJKFgzlUf5eAoF8BRIPfh4FTvUDQl+dUi5pc0w=")
|
||||
.expect("valid test fixture");
|
||||
let bytes = frame.repeat(20);
|
||||
|
||||
let decoder = decode_clip(bytes).expect("CBR MP3 should decode");
|
||||
assert!(decoder.total_duration().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formats_clip_time() {
|
||||
assert_eq!(format_time(Duration::ZERO), "0:00");
|
||||
assert_eq!(format_time(Duration::from_secs(65)), "1:05");
|
||||
assert_eq!(format_time(Duration::from_secs(3_661)), "61:01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn progress_handles_unknown_zero_and_clamps() {
|
||||
assert_eq!(progress(Duration::from_secs(1), None), 0.0);
|
||||
assert_eq!(progress(Duration::from_secs(1), Some(Duration::ZERO)), 0.0);
|
||||
assert_eq!(
|
||||
progress(Duration::from_secs(5), Some(Duration::from_secs(10))),
|
||||
0.5
|
||||
);
|
||||
assert_eq!(
|
||||
progress(Duration::from_secs(20), Some(Duration::from_secs(10))),
|
||||
1.0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seek_target_clamps_fraction() {
|
||||
let total = Duration::from_secs(100);
|
||||
assert_eq!(seek_target(0.25, total), Duration::from_secs(25));
|
||||
assert_eq!(seek_target(-1.0, total), Duration::ZERO);
|
||||
assert_eq!(seek_target(2.0, total), total);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ pub trait AudioBackend: Send + Sync {
|
||||
fn stop(&self) -> Result<(), AudioError>;
|
||||
}
|
||||
|
||||
pub mod clip_player;
|
||||
pub mod eq;
|
||||
pub mod gate;
|
||||
pub mod limiter;
|
||||
|
||||
@@ -125,6 +125,29 @@ pub fn is_probably_image(bytes: &[u8]) -> bool {
|
||||
png || jpeg || gif || bmp || webp
|
||||
}
|
||||
|
||||
/// Sniff the leading bytes for an audio container supported by the inline clip
|
||||
/// player. Audio remains [`AttachmentKind::File`] on the wire; this receiver-side
|
||||
/// check confirms that a filename-based player hint actually contains WAV, MP3,
|
||||
/// Ogg Vorbis, or FLAC data before playback is attempted.
|
||||
pub fn is_probably_audio(bytes: &[u8]) -> bool {
|
||||
let flac = bytes.starts_with(b"fLaC");
|
||||
let ogg = bytes.starts_with(b"OggS");
|
||||
let wav = bytes.len() >= 12 && bytes.starts_with(b"RIFF") && &bytes[8..12] == b"WAVE";
|
||||
let mp3_id3 = bytes.starts_with(b"ID3");
|
||||
let mp3_frame = bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] & 0xE0 == 0xE0;
|
||||
flac || ogg || wav || mp3_id3 || mp3_frame
|
||||
}
|
||||
|
||||
/// Whether a sanitized attachment name has an extension supported by the
|
||||
/// inline audio player. This is only a pre-fetch presentation hint; fetched
|
||||
/// bytes are confirmed with [`is_probably_audio`] before being decoded.
|
||||
pub fn looks_like_audio_name(name: &str) -> bool {
|
||||
let Some((_, extension)) = name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
matches!(extension.to_ascii_lowercase().as_str(), "wav" | "mp3" | "ogg" | "oga" | "flac")
|
||||
}
|
||||
|
||||
/// The attachment kind for some file bytes: [`AttachmentKind::Image`] if it
|
||||
/// sniffs as an image container, else [`AttachmentKind::File`].
|
||||
pub fn classify(bytes: &[u8]) -> AttachmentKind {
|
||||
@@ -242,9 +265,59 @@ mod tests {
|
||||
assert!(!is_probably_image(b""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sniffing_recognizes_supported_containers() {
|
||||
assert!(is_probably_audio(b"fLaC\0\0\0\x22"));
|
||||
assert!(is_probably_audio(b"OggS\0\x02"));
|
||||
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||
wav.extend_from_slice(b"WAVE");
|
||||
assert!(is_probably_audio(&wav));
|
||||
|
||||
assert!(is_probably_audio(b"ID3\x04\0\0"));
|
||||
assert!(is_probably_audio(&[0xFF, 0xFB, 0x90, 0x64]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sniffing_disambiguates_wav_from_webp() {
|
||||
let mut wav = b"RIFF".to_vec();
|
||||
wav.extend_from_slice(&[0, 0, 0, 0]);
|
||||
wav.extend_from_slice(b"WAVE");
|
||||
assert!(is_probably_audio(&wav));
|
||||
assert!(!is_probably_image(&wav));
|
||||
|
||||
let mut webp = b"RIFF".to_vec();
|
||||
webp.extend_from_slice(&[0, 0, 0, 0]);
|
||||
webp.extend_from_slice(b"WEBP");
|
||||
assert!(is_probably_image(&webp));
|
||||
assert!(!is_probably_audio(&webp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_sniffing_rejects_non_audio() {
|
||||
assert!(!is_probably_audio(b"%PDF-1.7"));
|
||||
assert!(!is_probably_audio(&[0x89, b'P', b'N', b'G']));
|
||||
assert!(!is_probably_audio(&[]));
|
||||
assert!(!is_probably_audio(&[0xFF]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_name_detection_is_case_insensitive() {
|
||||
for name in ["clip.wav", "clip.mp3", "clip.ogg", "clip.oga", "clip.flac"] {
|
||||
assert!(looks_like_audio_name(name), "{name}");
|
||||
}
|
||||
assert!(looks_like_audio_name("VOICE.MP3"));
|
||||
assert!(looks_like_audio_name("mix.FlAc"));
|
||||
assert!(!looks_like_audio_name("recording"));
|
||||
assert!(!looks_like_audio_name("notes.pdf"));
|
||||
assert!(!looks_like_audio_name("photo.webp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_maps_sniff_to_kind() {
|
||||
assert_eq!(classify(&[0xFF, 0xD8, 0xFF]), AttachmentKind::Image);
|
||||
assert_eq!(classify(b"fLaC\0\0\0\x22"), AttachmentKind::File);
|
||||
assert_eq!(classify(b"plain text"), AttachmentKind::File);
|
||||
}
|
||||
|
||||
|
||||
@@ -489,6 +489,15 @@ impl iroh::protocol::ProtocolHandler for FileRouter {
|
||||
// Finish either way: an unknown id closes with an empty body, which
|
||||
// the fetcher reads as a zero-length result and treats as "gone".
|
||||
let _ = send.finish();
|
||||
// CRITICAL: `finish()` only marks the stream's EOF — it does NOT wait
|
||||
// for the written bytes to be delivered and acknowledged. If we return
|
||||
// here the `connection` drops, and its CONNECTION_CLOSE can race ahead
|
||||
// of the still-in-flight stream data, so the fetcher's read aborts with
|
||||
// "connection lost". Wait for the fetcher to receive everything and
|
||||
// close the connection itself (it drops `conn` right after read_to_end);
|
||||
// that close is our signal the transfer landed. Bounded so a fetcher
|
||||
// that vanishes can't pin this task forever.
|
||||
let _ = tokio::time::timeout(FILE_FETCH_TIMEOUT, connection.closed()).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! End-to-end loopback test for the chat file-transfer plane (`FILES_ALPN`).
|
||||
//!
|
||||
//! Spins up two real iroh endpoints on localhost (relay disabled, addresses
|
||||
//! exchanged directly), registers the production [`FileRouter`] on each, serves a
|
||||
//! multi-megabyte blob on one side, and fetches it from the other through the
|
||||
//! real `serve_attachment`/`fetch_attachment` path.
|
||||
//!
|
||||
//! This is the regression guard for the "file fetch: read failed: connection
|
||||
//! lost" bug: the serve handler used to return (and drop the connection) the
|
||||
//! instant it called `finish()`, so the CONNECTION_CLOSE raced ahead of the
|
||||
//! still-in-flight stream data and the fetcher's `read_to_end` aborted. A blob
|
||||
//! large enough to span many packets makes that race deterministic — the fix
|
||||
//! (waiting on `connection.closed()` before returning) keeps the link up until
|
||||
//! the fetcher has the bytes.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::address_lookup::memory::MemoryLookup;
|
||||
use iroh::endpoint::presets;
|
||||
use iroh::protocol::Router;
|
||||
use iroh::{Endpoint, RelayMode};
|
||||
|
||||
use peerspeak::files::{ChatAttachment, AttachmentKind};
|
||||
use peerspeak::network::NetworkTransport;
|
||||
use peerspeak::network::iroh_impl::{FileRouter, IrohTransport};
|
||||
use peerspeak::protocol::FILES_ALPN;
|
||||
|
||||
struct Node {
|
||||
endpoint: Endpoint,
|
||||
transport: Arc<IrohTransport>,
|
||||
_router: Router,
|
||||
lookup: MemoryLookup,
|
||||
}
|
||||
|
||||
async fn spawn_node() -> Node {
|
||||
let lookup = MemoryLookup::new();
|
||||
let endpoint = Endpoint::builder(presets::Minimal)
|
||||
.secret_key(iroh::SecretKey::generate())
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.address_lookup(lookup.clone())
|
||||
.bind()
|
||||
.await
|
||||
.expect("bind endpoint");
|
||||
|
||||
let transport = Arc::new(IrohTransport::new(endpoint.clone()));
|
||||
// Mirror production: a persistent FileRouter bound to this session's transport
|
||||
// is what the router accepts inbound file fetches on.
|
||||
let file_router = FileRouter::new();
|
||||
file_router.bind(&transport);
|
||||
let router = Router::builder(endpoint.clone())
|
||||
.accept(FILES_ALPN, file_router)
|
||||
.spawn();
|
||||
|
||||
Node { endpoint, transport, _router: router, lookup }
|
||||
}
|
||||
|
||||
/// A pseudo-random-ish multi-megabyte payload spanning many QUIC packets, so a
|
||||
/// premature connection close on the serve side reliably corrupts/aborts the read.
|
||||
fn big_blob() -> Vec<u8> {
|
||||
(0..(2 * 1024 * 1024u32))
|
||||
.map(|i| (i.wrapping_mul(2654435761) >> 13) as u8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loopback_attachment_round_trips_intact() {
|
||||
let server = spawn_node().await;
|
||||
let client = spawn_node().await;
|
||||
|
||||
// Seed each side with the other's full address so direct dialing works.
|
||||
server.lookup.add_endpoint_info(client.endpoint.addr());
|
||||
client.lookup.add_endpoint_info(server.endpoint.addr());
|
||||
|
||||
let server_id = server.endpoint.id();
|
||||
let client_id = client.endpoint.id();
|
||||
|
||||
// The serve handler gates on room membership (the audio admission roster), so
|
||||
// the server must admit the client before it will answer the fetch.
|
||||
server.transport.admit_audio_sender(client_id);
|
||||
client.transport.admit_audio_sender(server_id);
|
||||
|
||||
// The fetcher dials the retained full address; seed it so fetch_attachment
|
||||
// doesn't have to fall back to a bare-id lookup.
|
||||
client.transport.connect_peer(server.endpoint.addr()).await;
|
||||
|
||||
let blob = big_blob();
|
||||
let id = [42u8; 32];
|
||||
server.transport.serve_attachment(id, Arc::new(blob.clone()));
|
||||
|
||||
let att = ChatAttachment {
|
||||
name: "exterior-landscape.jpg".to_string(),
|
||||
size: blob.len() as u64,
|
||||
kind: AttachmentKind::Image,
|
||||
id,
|
||||
};
|
||||
|
||||
let fetched = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
client.transport.fetch_attachment(server_id, &att),
|
||||
)
|
||||
.await
|
||||
.expect("fetch did not time out")
|
||||
.expect("fetch succeeded");
|
||||
|
||||
assert_eq!(fetched.len(), blob.len(), "fetched the full blob");
|
||||
assert_eq!(fetched, blob, "fetched bytes match served bytes exactly");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn loopback_unknown_id_reports_gone() {
|
||||
let server = spawn_node().await;
|
||||
let client = spawn_node().await;
|
||||
|
||||
server.lookup.add_endpoint_info(client.endpoint.addr());
|
||||
client.lookup.add_endpoint_info(server.endpoint.addr());
|
||||
|
||||
let server_id = server.endpoint.id();
|
||||
let client_id = client.endpoint.id();
|
||||
server.transport.admit_audio_sender(client_id);
|
||||
client.transport.admit_audio_sender(server_id);
|
||||
client.transport.connect_peer(server.endpoint.addr()).await;
|
||||
|
||||
// Never served — the handler closes with an empty body and the fetcher must
|
||||
// surface that as an error, not hang or return empty bytes.
|
||||
let att = ChatAttachment {
|
||||
name: "missing.bin".to_string(),
|
||||
size: 4096,
|
||||
kind: AttachmentKind::File,
|
||||
id: [7u8; 32],
|
||||
};
|
||||
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(30),
|
||||
client.transport.fetch_attachment(server_id, &att),
|
||||
)
|
||||
.await
|
||||
.expect("fetch did not time out");
|
||||
|
||||
assert!(result.is_err(), "unknown id should error, got {result:?}");
|
||||
}
|
||||