Fix live routing and Video FX overlay.
Hyprland binds called `ipc` twice so mapped keys never reached the daemon. Overlays now use mpv with wayland-app-id and JSON reload. Empty window_match matches nothing. Ctrl-c clears binds. Proven: send_shortcut to unfocused XWayland, mpv overlay class enboxer-vfx.
This commit is contained in:
parent
e05219f715
commit
feb2cdb4dc
@ -10,3 +10,7 @@
|
||||
- Stock loot map: assist → CTM on → Interact with Target once → delay → CTM off
|
||||
- Video FX: `grim` region capture, overlay viewer, hover pass-through to source slot
|
||||
- Unit tests for hotkey parse, passthrough, targets, loot sequence, example profile load
|
||||
- Bind IPC line no longer duplicated `ipc` (Hyprland hotkeys actually reach the daemon)
|
||||
- Video FX overlay is `mpv` (`--wayland-app-id=enboxer-vfx`) with JSON reload; `grim` capture
|
||||
- `enboxer run` clears binds on ctrl-c; empty `window_match` matches nothing
|
||||
- Live check: `send_shortcut` to an unfocused XWayland window; overlay window class `enboxer-vfx`
|
||||
|
||||
@ -23,6 +23,7 @@ tokio = { version = "1", features = [
|
||||
"net",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
"sync",
|
||||
"time",
|
||||
] }
|
||||
|
||||
2
PLAN.md
2
PLAN.md
@ -38,7 +38,7 @@ OpenCode must not invent tickets, add features, or “clean up” docs unless a
|
||||
- [x] Crate, profile, engine, Hyprland session, grim Video FX, tests
|
||||
- [x] `README.md`, `DESCRIPTION.md`, `CHANGELOG.md`, `AGENT.md`, `TASKS.md`, this file
|
||||
- [x] `opencode.json` with ponytail
|
||||
- [ ] Git remote + push (after Phase 1 is accepted and Grok has run ponytail-review)
|
||||
- [x] Git remote + push HTTPS `https://gitea.nettsi.de/en/enbuddy`
|
||||
|
||||
## Phase 1 — OpenCode tickets (all assigned below; do not ponytail-review until they are closed)
|
||||
|
||||
|
||||
@ -42,7 +42,7 @@ cargo build --release
|
||||
install -Dm755 target/release/enboxer ~/.local/bin/enboxer
|
||||
```
|
||||
|
||||
Needs Hyprland 0.56 (Lua dispatchers), `hyprctl`, `grim`. A viewer (`imv` or `feh`) for Video FX overlays.
|
||||
Needs Hyprland 0.56 (Lua dispatchers), `hyprctl`, `grim`, and `mpv` (Video FX overlay).
|
||||
|
||||
## Use
|
||||
|
||||
|
||||
@ -28,4 +28,6 @@ hl.bind("ALT + G", function() … end, { description = "enboxer:loot" })
|
||||
|
||||
Binds exist only while a managed game window or an `enboxer-vfx` overlay is focused.
|
||||
|
||||
Visible-pixel capture: `grim`. Covered windows: `hyprland-toplevel-export` (not wired yet). Overlay class: `enboxer-vfx`.
|
||||
Keys to **other** slots use `send_shortcut` on a window address. That reaches **XWayland** clients (Wine WoW) while they are not focused. Native Wayland terminals often ignore unfocused synthetic keys; the game clients this is built for are XWayland.
|
||||
|
||||
Visible-pixel capture: `grim`. Overlay: `mpv --wayland-app-id=enboxer-vfx` plus JSON IPC to reload frames. Covered windows: `hyprland-toplevel-export` (not wired yet).
|
||||
|
||||
49
src/hypr.rs
49
src/hypr.rs
@ -150,12 +150,7 @@ pub async fn replace_binds(binds: &[BindSpec], ipc: &str) -> Result<()> {
|
||||
(false, true) => "{ description = \"enboxer\", release = true }",
|
||||
(false, false) => "{ description = \"enboxer\" }",
|
||||
};
|
||||
let cmd = format!(
|
||||
"{} ipc --sock {} {}",
|
||||
shell_single(&b.ipc_bin),
|
||||
shell_single(ipc),
|
||||
b.ipc_args
|
||||
);
|
||||
let cmd = bind_command(&b.ipc_bin, ipc, &b.ipc_args);
|
||||
body.push_str(&format!(
|
||||
r#"
|
||||
do
|
||||
@ -192,6 +187,33 @@ fn shell_single(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\'', r#"'"'"'"#))
|
||||
}
|
||||
|
||||
/// Hyprland exec_cmd string. `args` is the IPC line after the binary, e.g. `hotkey Alt+G`.
|
||||
pub fn bind_command(bin: &str, sock: &str, args: &str) -> String {
|
||||
format!(
|
||||
"{} ipc --sock {} {}",
|
||||
shell_single(bin),
|
||||
shell_single(sock),
|
||||
args
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn apply_vfx_window_rules() -> Result<()> {
|
||||
eval_lua(
|
||||
r#"
|
||||
hl.window_rule({
|
||||
match = { class = "enboxer-vfx" },
|
||||
float = true,
|
||||
pin = true,
|
||||
no_anim = true,
|
||||
rounding = 0,
|
||||
border_size = 1,
|
||||
})
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn active_window() -> Result<Option<Client>> {
|
||||
let raw = hyprctl(["-j", "activewindow"]).await?;
|
||||
if raw.trim() == "{}" || raw.trim().is_empty() {
|
||||
@ -225,3 +247,18 @@ where
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::bind_command;
|
||||
|
||||
#[test]
|
||||
fn bind_command_has_one_ipc() {
|
||||
let cmd = bind_command("/tmp/enboxer", "/tmp/enboxer.sock", "hotkey Alt+G");
|
||||
assert_eq!(
|
||||
cmd,
|
||||
"'/tmp/enboxer' ipc --sock '/tmp/enboxer.sock' hotkey Alt+G"
|
||||
);
|
||||
assert_eq!(cmd.matches(" ipc ").count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
32
src/main.rs
32
src/main.rs
@ -122,7 +122,37 @@ async fn doctor(config: Option<PathBuf>) -> Result<()> {
|
||||
.await
|
||||
{
|
||||
Ok(_) => println!("grim: present"),
|
||||
Err(_) => println!("grim: MISSING (Video FX capture needs grim)"),
|
||||
Err(_) => {
|
||||
println!("grim: MISSING (Video FX capture needs grim)");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
match tokio::process::Command::new("mpv")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(_) => println!("mpv: present"),
|
||||
Err(_) => {
|
||||
println!("mpv: MISSING (Video FX overlay needs mpv)");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
match enboxer::hypr::dispatch_lua(
|
||||
"hl.dsp.send_shortcut({ window = \"class:enboxer-does-not-exist\", mods = \"\", key = \"a\" })",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => println!("send_shortcut: compositor accepts dispatcher"),
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
if msg.contains("window not found") {
|
||||
println!("send_shortcut: compositor accepts dispatcher");
|
||||
} else {
|
||||
println!("send_shortcut: FAIL {e}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(path) = config.or_else(|| {
|
||||
let p = profile::default_config_path();
|
||||
|
||||
152
src/session.rs
152
src/session.rs
@ -42,8 +42,18 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
|
||||
let _ = std::fs::remove_file(&sock);
|
||||
}
|
||||
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer"));
|
||||
tracing::info!(
|
||||
"profile {} slots={} match class={:?} title={:?}",
|
||||
profile.name,
|
||||
profile.slots,
|
||||
profile.window_match.class,
|
||||
profile.window_match.title
|
||||
);
|
||||
let session = Arc::new(Mutex::new(Session::new(profile, exe, sock.clone())?));
|
||||
let (vfx_tx, vfx_rx) = watch::channel(Vec::new());
|
||||
let hub = vfx::OverlayHub::new();
|
||||
|
||||
hypr::apply_vfx_window_rules().await.ok();
|
||||
|
||||
let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
|
||||
tracing::info!("ipc {}", sock.display());
|
||||
@ -65,34 +75,52 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
|
||||
}
|
||||
});
|
||||
|
||||
tokio::spawn(vfx::capture_loop(vfx_rx));
|
||||
{
|
||||
let hub = hub.clone();
|
||||
tokio::spawn(vfx::capture_loop(vfx_rx, hub));
|
||||
}
|
||||
|
||||
let s2 = session.clone();
|
||||
let hub2 = hub.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
if let Err(e) = refresh_slots(&s2, &vfx_tx).await {
|
||||
tracing::debug!("refresh: {e}");
|
||||
if let Err(e) = refresh_slots(&s2, &vfx_tx, &hub2).await {
|
||||
tracing::warn!("refresh: {e}");
|
||||
}
|
||||
sleep(Duration::from_millis(400)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let s3 = session.clone();
|
||||
hypr::listen_events(move |line| {
|
||||
let events = hypr::listen_events(move |line| {
|
||||
let s = s3.clone();
|
||||
async move {
|
||||
on_event(&s, &line).await;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
r = events => {
|
||||
tracing::warn!("hyprland event socket closed: {r:?}");
|
||||
}
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("ctrl-c");
|
||||
}
|
||||
}
|
||||
let _ = hypr::clear_binds().await;
|
||||
hub.kill_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn refresh_slots(
|
||||
session: &Arc<Mutex<Session>>,
|
||||
vfx_tx: &watch::Sender<Vec<FeedHit>>,
|
||||
hub: &Arc<vfx::OverlayHub>,
|
||||
) -> Result<()> {
|
||||
let clients = hypr::clients().await?;
|
||||
let aw = hypr::active_window().await.ok().flatten();
|
||||
let cursor = hypr::cursor_pos().await.ok();
|
||||
let mut g = session.lock().await;
|
||||
let class_re = g
|
||||
.engine
|
||||
@ -109,23 +137,28 @@ async fn refresh_slots(
|
||||
.as_deref()
|
||||
.and_then(|p| Regex::new(p).ok());
|
||||
|
||||
let mut matched: Vec<Client> = clients
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
if c.class == "enboxer-vfx" {
|
||||
return false;
|
||||
}
|
||||
let class_ok = class_re
|
||||
.as_ref()
|
||||
.map(|r| r.is_match(&c.class))
|
||||
.unwrap_or(true);
|
||||
let title_ok = title_re
|
||||
.as_ref()
|
||||
.map(|r| r.is_match(&c.title))
|
||||
.unwrap_or(true);
|
||||
class_ok && title_ok && c.mapped && !c.hidden
|
||||
})
|
||||
.collect();
|
||||
let has_filter = class_re.is_some() || title_re.is_some();
|
||||
let mut matched: Vec<Client> = if !has_filter {
|
||||
Vec::new()
|
||||
} else {
|
||||
clients
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
if c.class == "enboxer-vfx" {
|
||||
return false;
|
||||
}
|
||||
let class_ok = class_re
|
||||
.as_ref()
|
||||
.map(|r| r.is_match(&c.class))
|
||||
.unwrap_or(true);
|
||||
let title_ok = title_re
|
||||
.as_ref()
|
||||
.map(|r| r.is_match(&c.title))
|
||||
.unwrap_or(true);
|
||||
class_ok && title_ok && c.mapped && !c.hidden
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
|
||||
|
||||
let n = g.engine.profile.slots as usize;
|
||||
@ -151,7 +184,7 @@ async fn refresh_slots(
|
||||
}
|
||||
g.slots = new_slots;
|
||||
|
||||
if let Ok(Some(aw)) = hypr::active_window().await {
|
||||
if let Some(aw) = aw {
|
||||
let leader = g
|
||||
.slots
|
||||
.iter()
|
||||
@ -165,38 +198,58 @@ async fn refresh_slots(
|
||||
|| aw.title.starts_with("enboxer-vfx");
|
||||
if managed && g.engine.mode != Mode::Disabled {
|
||||
if !g.binds_on {
|
||||
install_binds(&mut g).await?;
|
||||
let specs = bind_specs(&g)?;
|
||||
let sock = g.sock.display().to_string();
|
||||
drop(g);
|
||||
hypr::replace_binds(&specs, &sock).await?;
|
||||
let mut g = session.lock().await;
|
||||
g.binds_on = true;
|
||||
tracing::info!("routing on ({} binds)", specs.len());
|
||||
return finish_vfx(g, vfx_tx, hub, cursor).await;
|
||||
}
|
||||
} else if g.binds_on {
|
||||
hypr::clear_binds().await.ok();
|
||||
g.binds_on = false;
|
||||
g.vfx_source = None;
|
||||
drop(g);
|
||||
hypr::clear_binds().await.ok();
|
||||
tracing::info!("routing off (focus left the team)");
|
||||
let g = session.lock().await;
|
||||
return finish_vfx(g, vfx_tx, hub, cursor).await;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((_, primary)) = g
|
||||
finish_vfx(g, vfx_tx, hub, cursor).await
|
||||
}
|
||||
|
||||
async fn finish_vfx(
|
||||
mut g: tokio::sync::MutexGuard<'_, Session>,
|
||||
vfx_tx: &watch::Sender<Vec<FeedHit>>,
|
||||
hub: &Arc<vfx::OverlayHub>,
|
||||
cursor: Option<(i32, i32)>,
|
||||
) -> Result<()> {
|
||||
let feeds = if let Some((_, primary)) = g
|
||||
.slots
|
||||
.iter()
|
||||
.find(|(s, _)| *s == g.engine.leader_slot)
|
||||
.cloned()
|
||||
{
|
||||
let feeds = vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots);
|
||||
g.vfx = feeds.clone();
|
||||
let _ = vfx_tx.send(feeds.clone());
|
||||
for f in &feeds {
|
||||
vfx::ensure_overlay_window(f).await.ok();
|
||||
}
|
||||
if let Ok((x, y)) = hypr::cursor_pos().await {
|
||||
let src = vfx::hit_test(&g.vfx, x, y)
|
||||
.filter(|h| h.pass_through)
|
||||
.map(|h| h.source_slot);
|
||||
if src != g.vfx_source {
|
||||
g.vfx_source = src;
|
||||
g.binds_on = false; // reinstall: VFX hover grabs extra keys
|
||||
}
|
||||
vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
g.vfx = feeds.clone();
|
||||
if let Some((x, y)) = cursor {
|
||||
let src = vfx::hit_test(&g.vfx, x, y)
|
||||
.filter(|h| h.pass_through)
|
||||
.map(|h| h.source_slot);
|
||||
if src != g.vfx_source {
|
||||
g.vfx_source = src;
|
||||
g.binds_on = false;
|
||||
}
|
||||
}
|
||||
drop(g);
|
||||
let _ = vfx_tx.send(feeds.clone());
|
||||
hub.sync(&feeds).await.ok();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@ -221,7 +274,7 @@ async fn on_event(session: &Arc<Mutex<Session>>, line: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn install_binds(g: &mut Session) -> Result<()> {
|
||||
fn bind_specs(g: &Session) -> Result<Vec<BindSpec>> {
|
||||
let passthrough = g.engine.profile.passthrough_set()?;
|
||||
let mut specs = Vec::new();
|
||||
let bin = g.exe.display().to_string();
|
||||
@ -234,7 +287,7 @@ async fn install_binds(g: &mut Session) -> Result<()> {
|
||||
specs.push(BindSpec {
|
||||
bind: parsed.hypr_bind(),
|
||||
ipc_bin: bin.clone(),
|
||||
ipc_args: format!("ipc hotkey {}", m.hotkey.0),
|
||||
ipc_args: format!("hotkey {}", m.hotkey.0),
|
||||
non_consuming: false,
|
||||
release: false,
|
||||
});
|
||||
@ -242,7 +295,7 @@ async fn install_binds(g: &mut Session) -> Result<()> {
|
||||
specs.push(BindSpec {
|
||||
bind: parsed.hypr_bind(),
|
||||
ipc_bin: bin.clone(),
|
||||
ipc_args: format!("ipc hotkey-release {}", m.hotkey.0),
|
||||
ipc_args: format!("hotkey-release {}", m.hotkey.0),
|
||||
non_consuming: false,
|
||||
release: true,
|
||||
});
|
||||
@ -261,7 +314,7 @@ async fn install_binds(g: &mut Session) -> Result<()> {
|
||||
specs.push(BindSpec {
|
||||
bind: parsed.hypr_bind(),
|
||||
ipc_bin: bin.clone(),
|
||||
ipc_args: format!("ipc hotkey {k}"),
|
||||
ipc_args: format!("hotkey {k}"),
|
||||
non_consuming: true,
|
||||
release: false,
|
||||
});
|
||||
@ -272,7 +325,7 @@ async fn install_binds(g: &mut Session) -> Result<()> {
|
||||
specs.push(BindSpec {
|
||||
bind: parsed.hypr_bind(),
|
||||
ipc_bin: bin.clone(),
|
||||
ipc_args: "ipc mode-cycle".into(),
|
||||
ipc_args: "mode-cycle".into(),
|
||||
non_consuming: false,
|
||||
release: false,
|
||||
});
|
||||
@ -285,16 +338,13 @@ async fn install_binds(g: &mut Session) -> Result<()> {
|
||||
specs.push(BindSpec {
|
||||
bind: k.clone(),
|
||||
ipc_bin: bin.clone(),
|
||||
ipc_args: format!("ipc vfxkey {k}"),
|
||||
ipc_args: format!("vfxkey {k}"),
|
||||
non_consuming: false,
|
||||
release: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
hypr::replace_binds(&specs, &g.sock.display().to_string()).await?;
|
||||
g.binds_on = true;
|
||||
tracing::info!("routing on ({} binds)", specs.len());
|
||||
Ok(())
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
async fn handle_ipc(session: Arc<Mutex<Session>>, stream: tokio::net::UnixStream) -> Result<()> {
|
||||
|
||||
209
src/vfx.rs
209
src/vfx.rs
@ -1,16 +1,20 @@
|
||||
//! Video FX: crop a region of a source slot onto an overlay on the primary.
|
||||
//!
|
||||
//! Capture uses `grim` (visible pixels). Stacked/occluded sources need
|
||||
//! hyprland-toplevel-export; that protocol exists on Hyprland 0.56 but the
|
||||
//! client is not wired yet. Pass-through still works from viewer geometry.
|
||||
//! Capture uses `grim` (visible pixels). The overlay is `mpv` with
|
||||
//! `--wayland-app-id=enboxer-vfx` and a JSON IPC socket so frames reload.
|
||||
|
||||
use crate::hypr::{self, Client};
|
||||
use crate::profile::{runtime_dir, VideoFx};
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::{interval, Duration};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::net::UnixStream;
|
||||
use tokio::process::{Child, Command};
|
||||
use tokio::sync::{watch, Mutex};
|
||||
use tokio::time::{interval, sleep, Duration};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FeedHit {
|
||||
@ -72,6 +76,10 @@ pub fn frame_path(name: &str) -> PathBuf {
|
||||
runtime_dir().join(format!("vfx-{name}.png"))
|
||||
}
|
||||
|
||||
fn ipc_path(name: &str) -> PathBuf {
|
||||
runtime_dir().join(format!("vfx-{name}.mpv"))
|
||||
}
|
||||
|
||||
pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Result<()> {
|
||||
if let Some(parent) = dest.parent() {
|
||||
tokio::fs::create_dir_all(parent).await.ok();
|
||||
@ -90,56 +98,169 @@ pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Keep PNG files fresh so an external viewer (`imv`, `mpv`, `feh --reload`) can show them.
|
||||
pub async fn capture_loop(rx: tokio::sync::watch::Receiver<Vec<FeedHit>>) {
|
||||
struct OverlayProc {
|
||||
child: Child,
|
||||
ipc: PathBuf,
|
||||
}
|
||||
|
||||
pub struct OverlayHub {
|
||||
procs: Mutex<HashMap<String, OverlayProc>>,
|
||||
}
|
||||
|
||||
impl OverlayHub {
|
||||
pub fn new() -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
procs: Mutex::new(HashMap::new()),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn sync(&self, feeds: &[FeedHit]) -> Result<()> {
|
||||
let names: Vec<String> = feeds.iter().map(|f| f.name.clone()).collect();
|
||||
{
|
||||
let mut procs = self.procs.lock().await;
|
||||
let stale: Vec<String> = procs
|
||||
.iter_mut()
|
||||
.filter_map(|(name, proc)| {
|
||||
let dead = matches!(proc.child.try_wait(), Ok(Some(_)));
|
||||
if !names.contains(name) || dead {
|
||||
Some(name.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for name in stale {
|
||||
if let Some(mut proc) = procs.remove(&name) {
|
||||
let _ = proc.child.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in feeds {
|
||||
self.ensure(f).await?;
|
||||
self.position(f).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure(&self, feed: &FeedHit) -> Result<()> {
|
||||
{
|
||||
let procs = self.procs.lock().await;
|
||||
if procs.contains_key(&feed.name) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
tokio::fs::create_dir_all(runtime_dir()).await.ok();
|
||||
let png = frame_path(&feed.name);
|
||||
if !png.exists() {
|
||||
capture_region(
|
||||
feed.source.0,
|
||||
feed.source.1,
|
||||
feed.source.2,
|
||||
feed.source.3,
|
||||
&png,
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
let ipc = ipc_path(&feed.name);
|
||||
let _ = tokio::fs::remove_file(&ipc).await;
|
||||
let title = format!("enboxer-vfx:{}", feed.name);
|
||||
let mut cmd = Command::new("mpv");
|
||||
cmd.args([
|
||||
"--wayland-app-id=enboxer-vfx",
|
||||
&format!("--title={title}"),
|
||||
"--idle=yes",
|
||||
"--force-window=immediate",
|
||||
&format!("--input-ipc-server={}", ipc.display()),
|
||||
"--no-input-default-bindings",
|
||||
"--no-osc",
|
||||
"--really-quiet",
|
||||
"--msg-level=all=error",
|
||||
"--image-display-duration=inf",
|
||||
"--loop-file=inf",
|
||||
"--no-config",
|
||||
"--no-audio",
|
||||
])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
if png.exists() {
|
||||
cmd.arg(&png);
|
||||
}
|
||||
let child = cmd.spawn().context("spawn mpv overlay")?;
|
||||
self.procs.lock().await.insert(
|
||||
feed.name.clone(),
|
||||
OverlayProc {
|
||||
child,
|
||||
ipc: ipc.clone(),
|
||||
},
|
||||
);
|
||||
for _ in 0..20 {
|
||||
if ipc.exists() {
|
||||
break;
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn position(&self, feed: &FeedHit) -> Result<()> {
|
||||
let title = format!("enboxer-vfx:{}", feed.name);
|
||||
let win = hypr::clients()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|c| c.class == "enboxer-vfx" || c.title == title || c.title.contains(&feed.name));
|
||||
if let Some(win) = win {
|
||||
let (x, y, w, h) = feed.viewer;
|
||||
hypr::move_resize_window(&win.address_selector(), x, y, w, h)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn show_frame(&self, name: &str, png: &Path) -> Result<()> {
|
||||
let ipc = {
|
||||
let procs = self.procs.lock().await;
|
||||
procs.get(name).map(|p| p.ipc.clone())
|
||||
};
|
||||
let Some(ipc) = ipc else {
|
||||
return Ok(());
|
||||
};
|
||||
if !ipc.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let cmd = serde_json::json!({
|
||||
"command": ["loadfile", png.to_string_lossy(), "replace"]
|
||||
});
|
||||
let mut s = UnixStream::connect(&ipc).await?;
|
||||
s.write_all(cmd.to_string().as_bytes()).await?;
|
||||
s.write_all(b"\n").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn kill_all(&self) {
|
||||
let mut procs = self.procs.lock().await;
|
||||
for (_, mut p) in procs.drain() {
|
||||
let _ = p.child.start_kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn capture_loop(rx: watch::Receiver<Vec<FeedHit>>, hub: Arc<OverlayHub>) {
|
||||
let mut tick = interval(Duration::from_millis(80));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let feeds = rx.borrow().clone();
|
||||
for f in feeds {
|
||||
let (x, y, w, h) = f.source;
|
||||
let dest = frame_path(&f.name);
|
||||
if let Err(e) = capture_region(x, y, w, h, &dest).await {
|
||||
tracing::debug!("vfx capture {}: {e}", f.name);
|
||||
let (x, y, w, h) = f.source;
|
||||
if capture_region(x, y, w, h, &dest).await.is_ok() {
|
||||
let _ = hub.show_frame(&f.name, &dest).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ensure_overlay_window(feed: &FeedHit) -> Result<()> {
|
||||
let path = frame_path(&feed.name);
|
||||
let class = "enboxer-vfx";
|
||||
let already = hypr::clients()
|
||||
.await?
|
||||
.into_iter()
|
||||
.any(|c| c.class == class && c.title.contains(&feed.name));
|
||||
if !already {
|
||||
let title = format!("enboxer-vfx:{}", feed.name);
|
||||
let file = path.display().to_string();
|
||||
// imv reloads on disk change; feh --reload too. Prefer imv.
|
||||
let cmd = format!(
|
||||
"imv -w {class} -t {title:?} {file:?} || feh --title {title:?} --reload 0.2 {file:?} || mpv --title={title:?} --loop=inf --image-display-duration=0.1 {file:?}"
|
||||
);
|
||||
hypr::eval_lua(&format!(
|
||||
"hl.dispatch(hl.dsp.exec_cmd({cmd:?}, {{ float = true, pin = true, class = {class:?} }}))"
|
||||
))
|
||||
.await
|
||||
.ok();
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
if let Some(win) = hypr::clients()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|c| c.class == class || c.title.contains(&feed.name))
|
||||
{
|
||||
let (x, y, w, h) = feed.viewer;
|
||||
hypr::move_resize_window(&win.address_selector(), x, y, w, h)
|
||||
.await
|
||||
.ok();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user