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:
en 2026-09-15 07:42:35 +02:00
parent e05219f715
commit feb2cdb4dc
9 changed files with 350 additions and 105 deletions

View File

@ -10,3 +10,7 @@
- Stock loot map: assist → CTM on → Interact with Target once → delay → CTM off - 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 - 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 - 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`

View File

@ -23,6 +23,7 @@ tokio = { version = "1", features = [
"net", "net",
"process", "process",
"rt-multi-thread", "rt-multi-thread",
"signal",
"sync", "sync",
"time", "time",
] } ] }

View File

@ -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] Crate, profile, engine, Hyprland session, grim Video FX, tests
- [x] `README.md`, `DESCRIPTION.md`, `CHANGELOG.md`, `AGENT.md`, `TASKS.md`, this file - [x] `README.md`, `DESCRIPTION.md`, `CHANGELOG.md`, `AGENT.md`, `TASKS.md`, this file
- [x] `opencode.json` with ponytail - [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) ## Phase 1 — OpenCode tickets (all assigned below; do not ponytail-review until they are closed)

View File

@ -42,7 +42,7 @@ cargo build --release
install -Dm755 target/release/enboxer ~/.local/bin/enboxer 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 ## Use

View File

@ -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. 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).

View File

@ -150,12 +150,7 @@ pub async fn replace_binds(binds: &[BindSpec], ipc: &str) -> Result<()> {
(false, true) => "{ description = \"enboxer\", release = true }", (false, true) => "{ description = \"enboxer\", release = true }",
(false, false) => "{ description = \"enboxer\" }", (false, false) => "{ description = \"enboxer\" }",
}; };
let cmd = format!( let cmd = bind_command(&b.ipc_bin, ipc, &b.ipc_args);
"{} ipc --sock {} {}",
shell_single(&b.ipc_bin),
shell_single(ipc),
b.ipc_args
);
body.push_str(&format!( body.push_str(&format!(
r#" r#"
do do
@ -192,6 +187,33 @@ fn shell_single(s: &str) -> String {
format!("'{}'", s.replace('\'', r#"'"'"'"#)) 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>> { pub async fn active_window() -> Result<Option<Client>> {
let raw = hyprctl(["-j", "activewindow"]).await?; let raw = hyprctl(["-j", "activewindow"]).await?;
if raw.trim() == "{}" || raw.trim().is_empty() { if raw.trim() == "{}" || raw.trim().is_empty() {
@ -225,3 +247,18 @@ where
} }
Ok(()) 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);
}
}

View File

@ -122,7 +122,37 @@ async fn doctor(config: Option<PathBuf>) -> Result<()> {
.await .await
{ {
Ok(_) => println!("grim: present"), 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(|| { if let Some(path) = config.or_else(|| {
let p = profile::default_config_path(); let p = profile::default_config_path();

View File

@ -42,8 +42,18 @@ pub async fn run(profile: Profile, sock: PathBuf) -> Result<()> {
let _ = std::fs::remove_file(&sock); let _ = std::fs::remove_file(&sock);
} }
let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("enboxer")); 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 session = Arc::new(Mutex::new(Session::new(profile, exe, sock.clone())?));
let (vfx_tx, vfx_rx) = watch::channel(Vec::new()); 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()))?; let listener = UnixListener::bind(&sock).with_context(|| format!("bind {}", sock.display()))?;
tracing::info!("ipc {}", 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 s2 = session.clone();
let hub2 = hub.clone();
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
if let Err(e) = refresh_slots(&s2, &vfx_tx).await { if let Err(e) = refresh_slots(&s2, &vfx_tx, &hub2).await {
tracing::debug!("refresh: {e}"); tracing::warn!("refresh: {e}");
} }
sleep(Duration::from_millis(400)).await; sleep(Duration::from_millis(400)).await;
} }
}); });
let s3 = session.clone(); let s3 = session.clone();
hypr::listen_events(move |line| { let events = hypr::listen_events(move |line| {
let s = s3.clone(); let s = s3.clone();
async move { async move {
on_event(&s, &line).await; on_event(&s, &line).await;
Ok(()) 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( async fn refresh_slots(
session: &Arc<Mutex<Session>>, session: &Arc<Mutex<Session>>,
vfx_tx: &watch::Sender<Vec<FeedHit>>, vfx_tx: &watch::Sender<Vec<FeedHit>>,
hub: &Arc<vfx::OverlayHub>,
) -> Result<()> { ) -> Result<()> {
let clients = hypr::clients().await?; 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 mut g = session.lock().await;
let class_re = g let class_re = g
.engine .engine
@ -109,23 +137,28 @@ async fn refresh_slots(
.as_deref() .as_deref()
.and_then(|p| Regex::new(p).ok()); .and_then(|p| Regex::new(p).ok());
let mut matched: Vec<Client> = clients let has_filter = class_re.is_some() || title_re.is_some();
.into_iter() let mut matched: Vec<Client> = if !has_filter {
.filter(|c| { Vec::new()
if c.class == "enboxer-vfx" { } else {
return false; clients
} .into_iter()
let class_ok = class_re .filter(|c| {
.as_ref() if c.class == "enboxer-vfx" {
.map(|r| r.is_match(&c.class)) return false;
.unwrap_or(true); }
let title_ok = title_re let class_ok = class_re
.as_ref() .as_ref()
.map(|r| r.is_match(&c.title)) .map(|r| r.is_match(&c.class))
.unwrap_or(true); .unwrap_or(true);
class_ok && title_ok && c.mapped && !c.hidden let title_ok = title_re
}) .as_ref()
.collect(); .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)); matched.sort_by_key(|c| (c.at[1], c.at[0], c.pid));
let n = g.engine.profile.slots as usize; let n = g.engine.profile.slots as usize;
@ -151,7 +184,7 @@ async fn refresh_slots(
} }
g.slots = new_slots; g.slots = new_slots;
if let Ok(Some(aw)) = hypr::active_window().await { if let Some(aw) = aw {
let leader = g let leader = g
.slots .slots
.iter() .iter()
@ -165,38 +198,58 @@ async fn refresh_slots(
|| aw.title.starts_with("enboxer-vfx"); || aw.title.starts_with("enboxer-vfx");
if managed && g.engine.mode != Mode::Disabled { if managed && g.engine.mode != Mode::Disabled {
if !g.binds_on { 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 { } else if g.binds_on {
hypr::clear_binds().await.ok();
g.binds_on = false; g.binds_on = false;
g.vfx_source = None; g.vfx_source = None;
drop(g);
hypr::clear_binds().await.ok();
tracing::info!("routing off (focus left the team)"); 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 .slots
.iter() .iter()
.find(|(s, _)| *s == g.engine.leader_slot) .find(|(s, _)| *s == g.engine.leader_slot)
.cloned() .cloned()
{ {
let feeds = vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots); vfx::build_hits(&g.engine.profile.video_fx, &primary, &g.slots)
g.vfx = feeds.clone(); } else {
let _ = vfx_tx.send(feeds.clone()); Vec::new()
for f in &feeds { };
vfx::ensure_overlay_window(f).await.ok(); g.vfx = feeds.clone();
} if let Some((x, y)) = cursor {
if let Ok((x, y)) = hypr::cursor_pos().await { let src = vfx::hit_test(&g.vfx, x, y)
let src = vfx::hit_test(&g.vfx, x, y) .filter(|h| h.pass_through)
.filter(|h| h.pass_through) .map(|h| h.source_slot);
.map(|h| h.source_slot); if src != g.vfx_source {
if src != g.vfx_source { g.vfx_source = src;
g.vfx_source = src; g.binds_on = false;
g.binds_on = false; // reinstall: VFX hover grabs extra keys
}
} }
} }
drop(g);
let _ = vfx_tx.send(feeds.clone());
hub.sync(&feeds).await.ok();
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 passthrough = g.engine.profile.passthrough_set()?;
let mut specs = Vec::new(); let mut specs = Vec::new();
let bin = g.exe.display().to_string(); let bin = g.exe.display().to_string();
@ -234,7 +287,7 @@ async fn install_binds(g: &mut Session) -> Result<()> {
specs.push(BindSpec { specs.push(BindSpec {
bind: parsed.hypr_bind(), bind: parsed.hypr_bind(),
ipc_bin: bin.clone(), ipc_bin: bin.clone(),
ipc_args: format!("ipc hotkey {}", m.hotkey.0), ipc_args: format!("hotkey {}", m.hotkey.0),
non_consuming: false, non_consuming: false,
release: false, release: false,
}); });
@ -242,7 +295,7 @@ async fn install_binds(g: &mut Session) -> Result<()> {
specs.push(BindSpec { specs.push(BindSpec {
bind: parsed.hypr_bind(), bind: parsed.hypr_bind(),
ipc_bin: bin.clone(), ipc_bin: bin.clone(),
ipc_args: format!("ipc hotkey-release {}", m.hotkey.0), ipc_args: format!("hotkey-release {}", m.hotkey.0),
non_consuming: false, non_consuming: false,
release: true, release: true,
}); });
@ -261,7 +314,7 @@ async fn install_binds(g: &mut Session) -> Result<()> {
specs.push(BindSpec { specs.push(BindSpec {
bind: parsed.hypr_bind(), bind: parsed.hypr_bind(),
ipc_bin: bin.clone(), ipc_bin: bin.clone(),
ipc_args: format!("ipc hotkey {k}"), ipc_args: format!("hotkey {k}"),
non_consuming: true, non_consuming: true,
release: false, release: false,
}); });
@ -272,7 +325,7 @@ async fn install_binds(g: &mut Session) -> Result<()> {
specs.push(BindSpec { specs.push(BindSpec {
bind: parsed.hypr_bind(), bind: parsed.hypr_bind(),
ipc_bin: bin.clone(), ipc_bin: bin.clone(),
ipc_args: "ipc mode-cycle".into(), ipc_args: "mode-cycle".into(),
non_consuming: false, non_consuming: false,
release: false, release: false,
}); });
@ -285,16 +338,13 @@ async fn install_binds(g: &mut Session) -> Result<()> {
specs.push(BindSpec { specs.push(BindSpec {
bind: k.clone(), bind: k.clone(),
ipc_bin: bin.clone(), ipc_bin: bin.clone(),
ipc_args: format!("ipc vfxkey {k}"), ipc_args: format!("vfxkey {k}"),
non_consuming: false, non_consuming: false,
release: false, release: false,
}); });
} }
} }
hypr::replace_binds(&specs, &g.sock.display().to_string()).await?; Ok(specs)
g.binds_on = true;
tracing::info!("routing on ({} binds)", specs.len());
Ok(())
} }
async fn handle_ipc(session: Arc<Mutex<Session>>, stream: tokio::net::UnixStream) -> Result<()> { async fn handle_ipc(session: Arc<Mutex<Session>>, stream: tokio::net::UnixStream) -> Result<()> {

View File

@ -1,16 +1,20 @@
//! Video FX: crop a region of a source slot onto an overlay on the primary. //! Video FX: crop a region of a source slot onto an overlay on the primary.
//! //!
//! Capture uses `grim` (visible pixels). Stacked/occluded sources need //! Capture uses `grim` (visible pixels). The overlay is `mpv` with
//! hyprland-toplevel-export; that protocol exists on Hyprland 0.56 but the //! `--wayland-app-id=enboxer-vfx` and a JSON IPC socket so frames reload.
//! client is not wired yet. Pass-through still works from viewer geometry.
use crate::hypr::{self, Client}; use crate::hypr::{self, Client};
use crate::profile::{runtime_dir, VideoFx}; use crate::profile::{runtime_dir, VideoFx};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use tokio::process::Command; use std::sync::Arc;
use tokio::time::{interval, Duration}; 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)] #[derive(Debug, Clone)]
pub struct FeedHit { pub struct FeedHit {
@ -72,6 +76,10 @@ pub fn frame_path(name: &str) -> PathBuf {
runtime_dir().join(format!("vfx-{name}.png")) 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<()> { pub async fn capture_region(x: i32, y: i32, w: i32, h: i32, dest: &Path) -> Result<()> {
if let Some(parent) = dest.parent() { if let Some(parent) = dest.parent() {
tokio::fs::create_dir_all(parent).await.ok(); 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(()) Ok(())
} }
/// Keep PNG files fresh so an external viewer (`imv`, `mpv`, `feh --reload`) can show them. struct OverlayProc {
pub async fn capture_loop(rx: tokio::sync::watch::Receiver<Vec<FeedHit>>) { 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)); let mut tick = interval(Duration::from_millis(80));
loop { loop {
tick.tick().await; tick.tick().await;
let feeds = rx.borrow().clone(); let feeds = rx.borrow().clone();
for f in feeds { for f in feeds {
let (x, y, w, h) = f.source;
let dest = frame_path(&f.name); let dest = frame_path(&f.name);
if let Err(e) = capture_region(x, y, w, h, &dest).await { let (x, y, w, h) = f.source;
tracing::debug!("vfx capture {}: {e}", f.name); 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;