Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
Some checks failed
dev release / build (push) Failing after 12s
Some checks failed
dev release / build (push) Failing after 12s
Builds out the full v1 scope: a vendored+patched openscreen subset for low-latency Cast Streaming (Mirroring receiver 0F5096E8) alongside the existing Cast V2/HLS and new DLNA/AVTransport casting paths, breadcastd's Idle/Casting state machine with a private IPC socket, the breadcast GTK4 popup as a thin IPC client, and bread.cast.*/bread.command.cast.* breadd integration (device discovery, start/stop, mirroring lifecycle events). Also adds bakery/systemd/Forgejo CI packaging. Validated end-to-end against a real Chromecast/Google TV: negotiated Cast Streaming session, live pipeline playback, and daemon+GUI click-to-cast/ stop through the actual popup.
This commit is contained in:
parent
887c29002f
commit
8c745d18e0
283 changed files with 36788 additions and 0 deletions
20
breadcast/Cargo.toml
Normal file
20
breadcast/Cargo.toml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[package]
|
||||
name = "breadcast"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "breadcast GTK4 popup: device picker and cast controls (thin IPC client of breadcastd)"
|
||||
|
||||
[[bin]]
|
||||
name = "breadcast"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
breadcast-core = { path = "../breadcast-core" }
|
||||
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] }
|
||||
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
gtk4-layer-shell = "0.8"
|
||||
62
breadcast/src/css.rs
Normal file
62
breadcast/src/css.rs
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
use bread_theme::Palette;
|
||||
|
||||
/// Plain GTK4 CSS (not libadwaita) — matches `bos-settings`'/`breadclip`'s
|
||||
/// precedent: libadwaita rows can't be width-constrained from outside a
|
||||
/// fixed-width popup panel the way a plain `ListBoxRow` can.
|
||||
pub fn build_css(palette: &Palette) -> String {
|
||||
format!(
|
||||
r#"
|
||||
.cast-panel {{
|
||||
background-color: {bg};
|
||||
border-radius: 12px;
|
||||
border: 1px solid alpha({fg}, 0.08);
|
||||
padding: 12px;
|
||||
}}
|
||||
.cast-title {{
|
||||
font-weight: 700;
|
||||
font-size: 1.1em;
|
||||
color: {fg};
|
||||
}}
|
||||
.cast-status-pill {{
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
font-size: 0.85em;
|
||||
background-color: {surface};
|
||||
color: {overlay};
|
||||
}}
|
||||
.cast-status-pill.casting {{
|
||||
background-color: alpha({accent}, 0.25);
|
||||
color: {accent};
|
||||
}}
|
||||
.cast-device-row {{
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: {fg};
|
||||
}}
|
||||
.cast-device-row:hover {{
|
||||
background-color: alpha({fg}, 0.06);
|
||||
}}
|
||||
.cast-device-row:selected {{
|
||||
background-color: alpha({accent}, 0.18);
|
||||
}}
|
||||
.cast-device-model {{
|
||||
font-size: 0.85em;
|
||||
color: {overlay};
|
||||
}}
|
||||
.cast-empty-label {{
|
||||
color: {overlay};
|
||||
padding: 24px 8px;
|
||||
}}
|
||||
.cast-stop-button {{
|
||||
background-color: alpha(#e35b5b, 0.15);
|
||||
color: #e35b5b;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
"#,
|
||||
bg = palette.background,
|
||||
fg = palette.foreground,
|
||||
surface = palette.color0,
|
||||
overlay = palette.color7,
|
||||
accent = palette.color4,
|
||||
)
|
||||
}
|
||||
68
breadcast/src/ipc_client.rs
Normal file
68
breadcast/src/ipc_client.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
//! A thin, synchronous client for breadcastd's IPC socket (see
|
||||
//! `breadcast_core::ipc` for the wire types and
|
||||
//! `breadcastd/src/ipc.rs` for the server side). No async runtime here —
|
||||
//! GTK4 already has its own main loop (glib), so this uses a plain
|
||||
//! blocking reader thread feeding an `mpsc::Receiver` the GTK side polls
|
||||
//! via `glib::timeout_add_local` (see `main.rs`), rather than pulling in
|
||||
//! tokio just for one socket.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use breadcast_core::ipc::{ClientRequest, ServerMessage, socket_path};
|
||||
|
||||
pub struct IpcClient {
|
||||
write_half: UnixStream,
|
||||
next_id: AtomicU64,
|
||||
}
|
||||
|
||||
impl IpcClient {
|
||||
/// Connects to breadcastd's socket and starts a background thread
|
||||
/// forwarding every parsed `ServerMessage` (both responses and pushed
|
||||
/// events — the caller distinguishes them, see `ServerMessage`'s
|
||||
/// variants) to the returned receiver, until the connection closes
|
||||
/// (breadcastd not running, or it exited).
|
||||
pub fn connect() -> Result<(Self, mpsc::Receiver<ServerMessage>)> {
|
||||
let path = socket_path()?;
|
||||
let write_half = UnixStream::connect(&path)
|
||||
.with_context(|| format!("failed to connect to breadcastd at {} — is it running?", path.display()))?;
|
||||
let read_half = write_half.try_clone().context("failed to duplicate the IPC socket handle")?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let reader = BufReader::new(read_half);
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match serde_json::from_str::<ServerMessage>(&line) {
|
||||
Ok(message) => {
|
||||
if tx.send(message).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("breadcast: malformed IPC message from breadcastd, ignoring: {e} ({line})"),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((Self { write_half, next_id: AtomicU64::new(1) }, rx))
|
||||
}
|
||||
|
||||
/// Sends a request and returns its id (so the caller can match it
|
||||
/// against the `ServerMessage::Response` that arrives later on the
|
||||
/// receiver from [`Self::connect`] — this method doesn't itself wait
|
||||
/// for a reply, matching the GTK main loop's non-blocking event style).
|
||||
pub fn send(&mut self, method: &str, params: serde_json::Value) -> Result<u64> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let request = ClientRequest { id, method: method.to_string(), params };
|
||||
let mut line = serde_json::to_string(&request).context("failed to serialize IPC request")?;
|
||||
line.push('\n');
|
||||
self.write_half.write_all(line.as_bytes()).context("failed to write to breadcastd's IPC socket")?;
|
||||
Ok(id)
|
||||
}
|
||||
}
|
||||
202
breadcast/src/main.rs
Normal file
202
breadcast/src/main.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! breadcast — GTK4 layer-shell popup: a thin IPC client of `breadcastd`
|
||||
//! (see `breadcast_core::ipc` for the wire protocol). Shows discovered Cast
|
||||
//! devices and start/stop controls; holds no pipeline or protocol code of
|
||||
//! its own — that all lives in `breadcastd`, so closing this popup never
|
||||
//! interrupts an active cast.
|
||||
|
||||
mod css;
|
||||
mod ipc_client;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use bread_theme::load_palette;
|
||||
use breadcast_core::ipc::{DeviceInfo, Protocol, ServerMessage, StateInfo};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Align, Application, Box as GBox, Button, Label, ListBox, Orientation, SelectionMode, glib};
|
||||
use ipc_client::IpcClient;
|
||||
|
||||
const PANEL_WIDTH: i32 = 360;
|
||||
|
||||
fn main() {
|
||||
let _singleton_guard = match bread_utils::singleton::toggle_or_kill("breadcast") {
|
||||
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
|
||||
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
|
||||
Err(e) => {
|
||||
eprintln!("breadcast: single-instance lock unavailable ({e}); continuing without it");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let app = Application::builder().application_id("com.breadway.breadcast").build();
|
||||
app.connect_activate(build_ui);
|
||||
app.run();
|
||||
}
|
||||
|
||||
fn build_ui(app: &Application) {
|
||||
bread_theme::gtk::apply_shared();
|
||||
bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette()));
|
||||
|
||||
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadcast");
|
||||
|
||||
let panel = GBox::new(Orientation::Vertical, 8);
|
||||
panel.add_css_class("cast-panel");
|
||||
panel.set_size_request(PANEL_WIDTH, -1);
|
||||
panel.set_halign(Align::Center);
|
||||
panel.set_valign(Align::Center);
|
||||
|
||||
let header = GBox::new(Orientation::Horizontal, 8);
|
||||
let title = Label::new(Some("Cast"));
|
||||
title.add_css_class("cast-title");
|
||||
title.set_hexpand(true);
|
||||
title.set_halign(Align::Start);
|
||||
let status_pill = Label::new(Some("Idle"));
|
||||
status_pill.add_css_class("cast-status-pill");
|
||||
header.append(&title);
|
||||
header.append(&status_pill);
|
||||
panel.append(&header);
|
||||
|
||||
let stop_button = Button::with_label("Stop mirroring");
|
||||
stop_button.add_css_class("cast-stop-button");
|
||||
stop_button.set_visible(false);
|
||||
panel.append(&stop_button);
|
||||
|
||||
let list = ListBox::new();
|
||||
list.set_selection_mode(SelectionMode::Browse);
|
||||
panel.append(&list);
|
||||
|
||||
let empty_label = Label::new(Some("Searching for devices..."));
|
||||
empty_label.add_css_class("cast-empty-label");
|
||||
panel.append(&empty_label);
|
||||
|
||||
window.set_child(Some(&panel));
|
||||
bread_utils::gtk_popup::close_on_outside_click(&window, &panel, {
|
||||
let window = window.clone();
|
||||
move || window.close()
|
||||
});
|
||||
|
||||
match IpcClient::connect() {
|
||||
Ok((client, rx)) => {
|
||||
let client = Rc::new(RefCell::new(client));
|
||||
let _ = client.borrow_mut().send("list_devices", serde_json::Value::Null);
|
||||
let _ = client.borrow_mut().send("get_state", serde_json::Value::Null);
|
||||
|
||||
list.connect_row_activated({
|
||||
let client = client.clone();
|
||||
move |_, row| {
|
||||
let Some(device_id) = (unsafe { row.data::<String>("device_id") }) else { return };
|
||||
let device_id = unsafe { device_id.as_ref() }.clone();
|
||||
let _ = client.borrow_mut().send("start_cast", serde_json::json!({ "device_id": device_id }));
|
||||
}
|
||||
});
|
||||
|
||||
stop_button.connect_clicked({
|
||||
let client = client.clone();
|
||||
move |_| {
|
||||
let _ = client.borrow_mut().send("stop_cast", serde_json::Value::Null);
|
||||
}
|
||||
});
|
||||
|
||||
let list = list.clone();
|
||||
let empty_label = empty_label.clone();
|
||||
let status_pill = status_pill.clone();
|
||||
let stop_button = stop_button.clone();
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
|
||||
while let Ok(message) = rx.try_recv() {
|
||||
handle_server_message(message, &list, &empty_label, &status_pill, &stop_button);
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
empty_label.set_label(&format!("breadcastd isn't running ({e})"));
|
||||
}
|
||||
}
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// Both a successful `"list_devices"`/`"get_state"` response and the
|
||||
/// corresponding pushed event carry the same JSON shape in `result`/`data`
|
||||
/// respectively — this normalizes both into one dispatch so there's only
|
||||
/// one device-list/state rendering path to keep in sync.
|
||||
fn handle_server_message(
|
||||
message: ServerMessage,
|
||||
list: &ListBox,
|
||||
empty_label: &Label,
|
||||
status_pill: &Label,
|
||||
stop_button: &Button,
|
||||
) {
|
||||
let payload = match message {
|
||||
ServerMessage::Event { event, data } => Some((event, data)),
|
||||
ServerMessage::Response { result: Some(result), .. } if result.is_array() => {
|
||||
Some(("device_list_changed".to_string(), result))
|
||||
}
|
||||
ServerMessage::Response { result: Some(result), .. } if result.get("state").is_some() => {
|
||||
Some(("state_changed".to_string(), result))
|
||||
}
|
||||
ServerMessage::Response { error: Some(error), .. } => {
|
||||
eprintln!("breadcast: request failed: {error}");
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let Some((event, data)) = payload else { return };
|
||||
match event.as_str() {
|
||||
"device_list_changed" => update_device_list(list, empty_label, data),
|
||||
"state_changed" => update_state(status_pill, stop_button, data),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_device_list(list: &ListBox, empty_label: &Label, data: serde_json::Value) {
|
||||
let Ok(devices) = serde_json::from_value::<Vec<DeviceInfo>>(data) else { return };
|
||||
|
||||
while let Some(row) = list.row_at_index(0) {
|
||||
list.remove(&row);
|
||||
}
|
||||
|
||||
empty_label.set_visible(devices.is_empty());
|
||||
list.set_visible(!devices.is_empty());
|
||||
|
||||
for device in &devices {
|
||||
let row = gtk4::ListBoxRow::new();
|
||||
unsafe { row.set_data("device_id", device.id.clone()) };
|
||||
|
||||
let row_box = GBox::new(Orientation::Vertical, 2);
|
||||
row_box.add_css_class("cast-device-row");
|
||||
let name = Label::new(Some(&device.name));
|
||||
name.set_halign(Align::Start);
|
||||
let model = Label::new(Some(&format!("{} · {}", device.model, protocol_label(device.protocol))));
|
||||
model.add_css_class("cast-device-model");
|
||||
model.set_halign(Align::Start);
|
||||
row_box.append(&name);
|
||||
row_box.append(&model);
|
||||
|
||||
row.set_child(Some(&row_box));
|
||||
list.append(&row);
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol_label(protocol: Protocol) -> &'static str {
|
||||
match protocol {
|
||||
Protocol::Cast => "Cast",
|
||||
Protocol::Dlna => "DLNA",
|
||||
}
|
||||
}
|
||||
|
||||
fn update_state(status_pill: &Label, stop_button: &Button, data: serde_json::Value) {
|
||||
let Ok(state) = serde_json::from_value::<StateInfo>(data) else { return };
|
||||
match state {
|
||||
StateInfo::Idle => {
|
||||
status_pill.set_label("Idle");
|
||||
status_pill.remove_css_class("casting");
|
||||
stop_button.set_visible(false);
|
||||
}
|
||||
StateInfo::Casting { device_name, .. } => {
|
||||
status_pill.set_label(&format!("Casting to {device_name}"));
|
||||
status_pill.add_css_class("casting");
|
||||
stop_button.set_visible(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue