Skip to main content

fission_shell_winit/
volume.rs

1use fission_core::{
2    VolumeAdjustDirection, VolumeAdjustRequest, VolumeError, VolumeLevel, VolumeSetRequest,
3    VolumeStream, ADJUST_VOLUME_LEVEL, GET_VOLUME_LEVEL, SET_VOLUME_LEVEL,
4};
5use fission_shell::async_host::AsyncRegistry;
6use std::process::Command;
7use std::sync::{Arc, Mutex};
8
9/// Host-side volume-control provider.
10pub trait VolumeHost: Send + Sync + 'static {
11    /// Reads the current level and mute state for one logical volume stream.
12    fn get_level(&self, stream: VolumeStream) -> Result<VolumeLevel, VolumeError>;
13    /// Sets the level and optional mute state for one logical volume stream.
14    fn set_level(&self, request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError>;
15    /// Adjusts one logical volume stream relative to its current state.
16    fn adjust_level(&self, request: VolumeAdjustRequest) -> Result<VolumeLevel, VolumeError>;
17}
18
19#[derive(Debug, Default)]
20pub struct UnsupportedVolumeHost;
21
22pub(crate) fn native_volume_host() -> impl VolumeHost {
23    if cfg!(any(target_os = "macos", target_os = "linux")) {
24        NativeVolumeHost
25    } else {
26        NativeVolumeHost
27    }
28}
29
30impl VolumeHost for UnsupportedVolumeHost {
31    fn get_level(&self, _stream: VolumeStream) -> Result<VolumeLevel, VolumeError> {
32        Err(VolumeError::unsupported("get_level"))
33    }
34
35    fn set_level(&self, _request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError> {
36        Err(VolumeError::unsupported("set_level"))
37    }
38
39    fn adjust_level(&self, _request: VolumeAdjustRequest) -> Result<VolumeLevel, VolumeError> {
40        Err(VolumeError::unsupported("adjust_level"))
41    }
42}
43
44#[derive(Debug, Default)]
45pub struct NativeVolumeHost;
46
47impl NativeVolumeHost {
48    fn get_system_level(&self, stream: VolumeStream) -> Result<VolumeLevel, VolumeError> {
49        if cfg!(target_os = "macos") {
50            let output = Command::new("osascript")
51                .args(["-e", "output volume of (get volume settings)"])
52                .output()
53                .map_err(volume_command_error)?;
54            if !output.status.success() {
55                return Err(volume_status_error(output));
56            }
57            let level = String::from_utf8_lossy(&output.stdout)
58                .trim()
59                .parse::<u8>()
60                .map_err(|error| VolumeError::new("parse_error", error.to_string()))?
61                .min(100);
62            return Ok(VolumeLevel {
63                stream,
64                level,
65                muted: false,
66            });
67        }
68
69        if cfg!(target_os = "linux") && command_exists("pactl") {
70            let output = Command::new("pactl")
71                .args(["get-sink-volume", "@DEFAULT_SINK@"])
72                .output()
73                .map_err(volume_command_error)?;
74            if !output.status.success() {
75                return Err(volume_status_error(output));
76            }
77            let text = String::from_utf8_lossy(&output.stdout);
78            let level = parse_pactl_percent(&text)
79                .ok_or_else(|| VolumeError::new("parse_error", "failed to parse pactl volume"))?;
80            let muted = self.linux_muted().unwrap_or(false);
81            return Ok(VolumeLevel {
82                stream,
83                level,
84                muted,
85            });
86        }
87
88        Err(VolumeError::unsupported("get_level"))
89    }
90
91    fn set_system_level(&self, request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError> {
92        let level = request.level.min(100);
93        if cfg!(target_os = "macos") {
94            Command::new("osascript")
95                .args(["-e", &format!("set volume output volume {level}")])
96                .status()
97                .map_err(volume_command_error)?;
98            if let Some(muted) = request.muted {
99                Command::new("osascript")
100                    .args([
101                        "-e",
102                        if muted {
103                            "set volume output muted true"
104                        } else {
105                            "set volume output muted false"
106                        },
107                    ])
108                    .status()
109                    .map_err(volume_command_error)?;
110            }
111            return self.get_system_level(request.stream);
112        }
113
114        if cfg!(target_os = "linux") && command_exists("pactl") {
115            Command::new("pactl")
116                .args(["set-sink-volume", "@DEFAULT_SINK@", &format!("{level}%")])
117                .status()
118                .map_err(volume_command_error)?;
119            if let Some(muted) = request.muted {
120                Command::new("pactl")
121                    .args([
122                        "set-sink-mute",
123                        "@DEFAULT_SINK@",
124                        if muted { "1" } else { "0" },
125                    ])
126                    .status()
127                    .map_err(volume_command_error)?;
128            }
129            return self.get_system_level(request.stream);
130        }
131
132        Err(VolumeError::unsupported("set_level"))
133    }
134
135    fn linux_muted(&self) -> Result<bool, VolumeError> {
136        let output = Command::new("pactl")
137            .args(["get-sink-mute", "@DEFAULT_SINK@"])
138            .output()
139            .map_err(volume_command_error)?;
140        if !output.status.success() {
141            return Err(volume_status_error(output));
142        }
143        let text = String::from_utf8_lossy(&output.stdout);
144        Ok(text.contains("yes"))
145    }
146}
147
148impl VolumeHost for NativeVolumeHost {
149    fn get_level(&self, stream: VolumeStream) -> Result<VolumeLevel, VolumeError> {
150        self.get_system_level(stream)
151    }
152
153    fn set_level(&self, request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError> {
154        self.set_system_level(request)
155    }
156
157    fn adjust_level(&self, request: VolumeAdjustRequest) -> Result<VolumeLevel, VolumeError> {
158        let current = self.get_level(request.stream)?;
159        let level = match request.direction {
160            VolumeAdjustDirection::Up => current.level.saturating_add(request.step).min(100),
161            VolumeAdjustDirection::Down => current.level.saturating_sub(request.step),
162        };
163        self.set_level(VolumeSetRequest {
164            stream: request.stream,
165            level,
166            muted: None,
167        })
168    }
169}
170
171#[derive(Debug)]
172pub struct MemoryVolumeHost {
173    level: Arc<Mutex<VolumeLevel>>,
174}
175
176impl Default for MemoryVolumeHost {
177    fn default() -> Self {
178        Self {
179            level: Arc::new(Mutex::new(VolumeLevel {
180                stream: VolumeStream::Media,
181                level: 50,
182                muted: false,
183            })),
184        }
185    }
186}
187
188impl MemoryVolumeHost {
189    pub fn current(&self) -> VolumeLevel {
190        self.level.lock().unwrap().clone()
191    }
192}
193
194impl VolumeHost for MemoryVolumeHost {
195    fn get_level(&self, stream: VolumeStream) -> Result<VolumeLevel, VolumeError> {
196        let mut level = self.level.lock().unwrap().clone();
197        level.stream = stream;
198        Ok(level)
199    }
200
201    fn set_level(&self, request: VolumeSetRequest) -> Result<VolumeLevel, VolumeError> {
202        let mut level = self.level.lock().unwrap();
203        level.stream = request.stream;
204        level.level = request.level.min(100);
205        if let Some(muted) = request.muted {
206            level.muted = muted;
207        }
208        Ok(level.clone())
209    }
210
211    fn adjust_level(&self, request: VolumeAdjustRequest) -> Result<VolumeLevel, VolumeError> {
212        let mut level = self.level.lock().unwrap();
213        level.stream = request.stream;
214        level.level = match request.direction {
215            VolumeAdjustDirection::Up => level.level.saturating_add(request.step).min(100),
216            VolumeAdjustDirection::Down => level.level.saturating_sub(request.step),
217        };
218        Ok(level.clone())
219    }
220}
221
222fn command_exists(name: &str) -> bool {
223    std::env::var_os("PATH")
224        .and_then(|paths| {
225            std::env::split_paths(&paths)
226                .map(|path| path.join(name))
227                .find(|path| path.is_file())
228        })
229        .is_some()
230}
231
232fn parse_pactl_percent(text: &str) -> Option<u8> {
233    text.split_whitespace()
234        .find_map(|part| part.strip_suffix('%'))
235        .and_then(|number| number.parse::<u16>().ok())
236        .map(|level| level.min(100) as u8)
237}
238
239fn volume_command_error(error: std::io::Error) -> VolumeError {
240    VolumeError::new("host_error", error.to_string())
241}
242
243fn volume_status_error(output: std::process::Output) -> VolumeError {
244    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
245    VolumeError::new(
246        "host_error",
247        if stderr.is_empty() {
248            format!("volume command exited with {}", output.status)
249        } else {
250            stderr
251        },
252    )
253}
254
255pub(crate) fn register_volume_capabilities(
256    async_registry: &mut AsyncRegistry,
257    host: Arc<dyn VolumeHost>,
258) {
259    let get_host = host.clone();
260    async_registry.register_operation_capability(GET_VOLUME_LEVEL, move |request, _| {
261        let host = get_host.clone();
262        async move { host.get_level(request) }
263    });
264
265    let set_host = host.clone();
266    async_registry.register_operation_capability(SET_VOLUME_LEVEL, move |request, _| {
267        let host = set_host.clone();
268        async move { host.set_level(request) }
269    });
270
271    async_registry.register_operation_capability(ADJUST_VOLUME_LEVEL, move |request, _| {
272        let host = host.clone();
273        async move { host.adjust_level(request) }
274    });
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    #[test]
282    fn unsupported_host_reports_errors() {
283        let host = UnsupportedVolumeHost;
284        assert!(host.get_level(VolumeStream::Media).is_err());
285    }
286
287    #[test]
288    fn memory_host_sets_and_adjusts_volume() {
289        let host = MemoryVolumeHost::default();
290        let set = host
291            .set_level(VolumeSetRequest {
292                stream: VolumeStream::Media,
293                level: 80,
294                muted: Some(false),
295            })
296            .unwrap();
297        assert_eq!(set.level, 80);
298
299        let adjusted = host
300            .adjust_level(VolumeAdjustRequest {
301                stream: VolumeStream::Media,
302                direction: VolumeAdjustDirection::Down,
303                step: 15,
304            })
305            .unwrap();
306        assert_eq!(adjusted.level, 65);
307    }
308
309    #[test]
310    fn pactl_volume_parser_extracts_first_percentage() {
311        let output =
312            "Volume: front-left: 32768 / 50% / -18.06 dB, front-right: 32768 / 50% / -18.06 dB";
313        assert_eq!(parse_pactl_percent(output), Some(50));
314        assert_eq!(parse_pactl_percent("no percentage"), None);
315    }
316
317    #[test]
318    fn native_volume_reports_unsupported_when_host_has_no_mixer() {
319        if cfg!(target_os = "macos") || (cfg!(target_os = "linux") && command_exists("pactl")) {
320            return;
321        }
322
323        let host = NativeVolumeHost;
324        assert_eq!(
325            host.get_level(VolumeStream::Media).unwrap_err().code,
326            "unsupported"
327        );
328    }
329}