use anyhow::{bail, Context, Result};
use std::process::Command;
#[derive(Clone)]
pub struct Caster {
pub addr: String,
pub port: u16,
}
impl Caster {
pub fn new(addr: impl Into<String>, port: u16) -> Self {
Self {
addr: addr.into(),
port,
}
}
pub fn load(&self, stream_url: &str, content_type: Option<&str>) -> Result<()> {
let port_str = self.port.to_string();
let mut args: Vec<&str> = vec![
"load",
stream_url,
"--addr",
&self.addr,
"--port",
&port_str,
"--detach",
];
if let Some(ct) = content_type {
args.push("-c");
args.push(ct);
}
let status = Command::new("go-chromecast")
.args(&args)
.status()
.context("failed to run go-chromecast — is it installed?")?;
if !status.success() {
bail!("go-chromecast exited with status {status}");
}
Ok(())
}
pub fn stop(&self) -> Result<()> {
self.gc(&["stop"])
}
pub fn toggle_pause(&self) -> Result<()> {
self.gc(&["togglepause"])
}
pub fn mute(&self) -> Result<()> {
self.gc(&["mute"])
}
pub fn unmute(&self) -> Result<()> {
self.gc(&["unmute"])
}
pub fn volume_up(&self) -> Result<()> {
self.gc(&["volume-up"])
}
pub fn volume_down(&self) -> Result<()> {
self.gc(&["volume-down"])
}
pub fn seek_forward(&self, seconds: u32) -> Result<()> {
self.gc(&["seek", &seconds.to_string()])
}
pub fn seek_back(&self, seconds: u32) -> Result<()> {
self.gc(&["rewind", &seconds.to_string()])
}
fn gc(&self, args: &[&str]) -> Result<()> {
let status = Command::new("go-chromecast")
.args(args)
.args(["--addr", &self.addr, "--port", &self.port.to_string()])
.status()
.with_context(|| format!("go-chromecast {} failed", args[0]))?;
if !status.success() {
bail!("go-chromecast {} exited with {status}", args[0]);
}
Ok(())
}
pub fn status_raw(&self) -> Result<String> {
let out = Command::new("go-chromecast")
.args([
"status",
"--addr",
&self.addr,
"--port",
&self.port.to_string(),
])
.output()
.context("go-chromecast status failed")?;
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
pub fn is_playing(&self) -> bool {
self.status_raw()
.map(|s| {
s.contains("PLAYING") || s.contains("PAUSED") || s.contains("BUFFERING")
})
.unwrap_or(false)
}
pub fn time_remaining(&self) -> Option<u64> {
let raw = self.status_raw().ok()?;
let marker = "time remaining=";
let pos = raw.find(marker)?;
let after = &raw[pos + marker.len()..];
let field: String = after.chars().take_while(|&c| c != ',').collect();
let left = field.split('/').next()?.trim_end_matches('s');
left.parse().ok()
}
pub fn position_duration(&self) -> Option<(u64, u64)> {
let (remaining, duration) = self.parse_time_field()?;
let pos = duration.saturating_sub(remaining);
Some((pos, duration))
}
fn parse_time_field(&self) -> Option<(u64, u64)> {
let raw = self.status_raw().ok()?;
let marker = "time remaining=";
let pos = raw.find(marker)?;
let after = &raw[pos + marker.len()..];
let field: String = after.chars().take_while(|&c| c != ',').collect();
let (left, right) = field.split_once('/')?;
let remaining: u64 = left.trim_end_matches('s').parse().ok()?;
let right = right.trim_end_matches('s');
if right.starts_with('-') {
return None;
}
let duration: u64 = right.parse().ok()?;
Some((remaining, duration))
}
}