feat: screen-share indicator
Replace the wl-screenrec bar widget with a generic capture indicator driven by fenster's "capture" IPC event (riverfx capture_sessions). It shows a red dot while any output/window is captured, reveals a summary on hover with details in the tooltip, and stops a running sims recording on click.
This commit is contained in:
+5
-1
@@ -35,6 +35,7 @@ from .modules.vinyl import VinylButton
|
|||||||
from .services.org import OrgService
|
from .services.org import OrgService
|
||||||
from .config import CALENDAR, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL
|
from .config import CALENDAR, NOTIFICATIONS, ORG, POWER, SCREENREC, STYLIX, VINYL
|
||||||
from .services.fenster import get_i3_connection
|
from .services.fenster import get_i3_connection
|
||||||
|
from .services.capture import get_capture_service
|
||||||
from .services.notification_history import NotificationHistoryService
|
from .services.notification_history import NotificationHistoryService
|
||||||
from .services.screenrec import ScreenrecService
|
from .services.screenrec import ScreenrecService
|
||||||
|
|
||||||
@@ -43,6 +44,9 @@ from fabric.notifications import Notifications
|
|||||||
|
|
||||||
tray = SystemTray(name="system-tray", spacing=4)
|
tray = SystemTray(name="system-tray", spacing=4)
|
||||||
get_i3_connection()
|
get_i3_connection()
|
||||||
|
# Subscribe before the main loop runs so fenster's replayed capture state
|
||||||
|
# (sent right after SUBSCRIBE) is not missed.
|
||||||
|
get_capture_service()
|
||||||
|
|
||||||
dummy = Window(visible=False)
|
dummy = Window(visible=False)
|
||||||
finder = FuzzyWindowFinder()
|
finder = FuzzyWindowFinder()
|
||||||
@@ -239,7 +243,7 @@ def spawn_bars():
|
|||||||
display=output_name,
|
display=output_name,
|
||||||
tray=tray if i == 0 else None,
|
tray=tray if i == 0 else None,
|
||||||
monitor=i,
|
monitor=i,
|
||||||
screenrec_service=screenrec_service if i == 0 else None,
|
screenrec_service=screenrec_service,
|
||||||
control_center=control_center if i == 0 else None,
|
control_center=control_center if i == 0 else None,
|
||||||
org_service=org_service,
|
org_service=org_service,
|
||||||
org_dropdown=org_dropdown if i == 0 else None,
|
org_dropdown=org_dropdown if i == 0 else None,
|
||||||
|
|||||||
+3
-6
@@ -10,7 +10,7 @@ from sims.modules.control_center import ControlCenter
|
|||||||
from sims.modules.notmuch import NotmuchWidget
|
from sims.modules.notmuch import NotmuchWidget
|
||||||
from sims.modules.buddy import Buddy
|
from sims.modules.buddy import Buddy
|
||||||
from sims.modules.org import OrgDropdown, OrgWidget
|
from sims.modules.org import OrgDropdown, OrgWidget
|
||||||
from sims.modules.screenrec import ScreenrecWidget
|
from sims.modules.capture import CaptureWidget
|
||||||
from sims.modules.microphone import MicrophoneWidget
|
from sims.modules.microphone import MicrophoneWidget
|
||||||
from fabric.widgets.wayland import WaylandWindow as Window
|
from fabric.widgets.wayland import WaylandWindow as Window
|
||||||
from fabric.system_tray.widgets import SystemTray
|
from fabric.system_tray.widgets import SystemTray
|
||||||
@@ -103,9 +103,7 @@ class StatusBar(Window):
|
|||||||
notmuch_service = self.notmuch.service if self.notmuch is not None else None
|
notmuch_service = self.notmuch.service if self.notmuch is not None else None
|
||||||
self.buddy = Buddy(notmuch_service=notmuch_service)
|
self.buddy = Buddy(notmuch_service=notmuch_service)
|
||||||
|
|
||||||
self.screenrec = None
|
self.capture = CaptureWidget(screenrec_service=screenrec_service)
|
||||||
if screenrec_service is not None:
|
|
||||||
self.screenrec = ScreenrecWidget(screenrec_service)
|
|
||||||
|
|
||||||
self.org = None
|
self.org = None
|
||||||
if org_service is not None:
|
if org_service is not None:
|
||||||
@@ -135,8 +133,7 @@ class StatusBar(Window):
|
|||||||
if self.notmuch:
|
if self.notmuch:
|
||||||
end_container_children.append(self.notmuch)
|
end_container_children.append(self.notmuch)
|
||||||
|
|
||||||
if self.screenrec:
|
end_container_children.append(self.capture)
|
||||||
end_container_children.append(self.screenrec)
|
|
||||||
|
|
||||||
end_container_children.append(self.date_time)
|
end_container_children.append(self.date_time)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Screen-share indicator.
|
||||||
|
|
||||||
|
Minimal by default: a single red dot, visible only while fenster reports an
|
||||||
|
active screen capture (portal screen shares, wl-screenrec, ...). Hovering
|
||||||
|
slides out a short summary of what is being captured; the tooltip lists every
|
||||||
|
captured output/window. Clicking stops a running sims screen recording.
|
||||||
|
"""
|
||||||
|
from fabric.widgets.box import Box
|
||||||
|
from fabric.widgets.button import Button
|
||||||
|
from fabric.widgets.label import Label
|
||||||
|
from fabric.widgets.revealer import Revealer
|
||||||
|
|
||||||
|
from sims.services.capture import CaptureService, get_capture_service
|
||||||
|
from sims.services.screenrec import ScreenrecService
|
||||||
|
|
||||||
|
SUMMARY_MAX_CHARS = 40
|
||||||
|
|
||||||
|
|
||||||
|
def _window_label(window: dict) -> str:
|
||||||
|
return window.get("app_id") or window.get("title") or f"window {window.get('id')}"
|
||||||
|
|
||||||
|
|
||||||
|
def _sessions_suffix(entry: dict) -> str:
|
||||||
|
sessions = entry.get("sessions", 1)
|
||||||
|
return f" ({sessions}×)" if isinstance(sessions, int) and sessions > 1 else ""
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureWidget(Button):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
service: CaptureService | None = None,
|
||||||
|
screenrec_service: ScreenrecService | None = None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
self._service = service or get_capture_service()
|
||||||
|
self._screenrec = screenrec_service
|
||||||
|
|
||||||
|
self._dot = Label(name="capture-dot", label="●")
|
||||||
|
self._summary = Label(name="capture-summary", label="")
|
||||||
|
self._revealer = Revealer(
|
||||||
|
child=self._summary,
|
||||||
|
transition_type="slide-left",
|
||||||
|
transition_duration=200,
|
||||||
|
)
|
||||||
|
|
||||||
|
super().__init__(
|
||||||
|
name="capture-widget",
|
||||||
|
child=Box(orientation="h", children=[self._revealer, self._dot]),
|
||||||
|
on_clicked=self._on_clicked,
|
||||||
|
visible=False,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
self.set_no_show_all(True)
|
||||||
|
|
||||||
|
self.connect("enter-notify-event", lambda *_: self._revealer.reveal())
|
||||||
|
self.connect("leave-notify-event", lambda *_: self._revealer.unreveal())
|
||||||
|
|
||||||
|
self._service.connect("capture-changed", self._update)
|
||||||
|
if self._screenrec is not None:
|
||||||
|
self._screenrec.connect("recording-changed", self._update)
|
||||||
|
self._update()
|
||||||
|
|
||||||
|
def _on_clicked(self, *_):
|
||||||
|
if self._screenrec is not None and self._screenrec.recording:
|
||||||
|
self._screenrec.stop()
|
||||||
|
|
||||||
|
def _update(self, *_):
|
||||||
|
outputs = self._service.outputs
|
||||||
|
windows = self._service.windows
|
||||||
|
|
||||||
|
if not (outputs or windows):
|
||||||
|
self._revealer.unreveal()
|
||||||
|
self.set_visible(False)
|
||||||
|
return
|
||||||
|
|
||||||
|
names = [o.get("name", "?") for o in outputs] + [_window_label(w) for w in windows]
|
||||||
|
summary = "Sharing " + ", ".join(names)
|
||||||
|
if len(summary) > SUMMARY_MAX_CHARS:
|
||||||
|
summary = summary[: SUMMARY_MAX_CHARS - 1] + "…"
|
||||||
|
self._summary.set_label(summary)
|
||||||
|
|
||||||
|
lines = [f"Monitor {o.get('name', '?')}{_sessions_suffix(o)}" for o in outputs]
|
||||||
|
for w in windows:
|
||||||
|
title = w.get("title")
|
||||||
|
label = _window_label(w)
|
||||||
|
detail = f"{label} — {title}" if title and title != label else label
|
||||||
|
lines.append(f"Window {detail}{_sessions_suffix(w)}")
|
||||||
|
if self._screenrec is not None and self._screenrec.recording:
|
||||||
|
lines.append("Click to stop recording")
|
||||||
|
self.set_tooltip_text("\n".join(lines))
|
||||||
|
|
||||||
|
self.set_visible(True)
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import time
|
|
||||||
|
|
||||||
from fabric.utils import invoke_repeater
|
|
||||||
from fabric.widgets.box import Box
|
|
||||||
from fabric.widgets.button import Button
|
|
||||||
from fabric.widgets.label import Label
|
|
||||||
from gi.repository import GLib
|
|
||||||
|
|
||||||
from sims.services.screenrec import ScreenrecService
|
|
||||||
|
|
||||||
|
|
||||||
class ScreenrecWidget(Button):
|
|
||||||
def __init__(self, service: ScreenrecService, **kwargs):
|
|
||||||
self._service = service
|
|
||||||
self._timer_id: int | None = None
|
|
||||||
|
|
||||||
self._dot = Label(name="screenrec-dot", label="●")
|
|
||||||
self._elapsed = Label(name="screenrec-elapsed", label="00:00")
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
name="screenrec-widget",
|
|
||||||
child=Box(
|
|
||||||
orientation="h",
|
|
||||||
spacing=6,
|
|
||||||
children=[self._dot, self._elapsed],
|
|
||||||
),
|
|
||||||
on_clicked=lambda *_: self._service.stop(),
|
|
||||||
visible=False,
|
|
||||||
**kwargs,
|
|
||||||
)
|
|
||||||
self.set_no_show_all(True)
|
|
||||||
|
|
||||||
self._service.connect("recording-changed", self._on_recording_changed)
|
|
||||||
if self._service.recording:
|
|
||||||
self._on_recording_changed(self._service, True)
|
|
||||||
|
|
||||||
def _on_recording_changed(self, _service, recording: bool):
|
|
||||||
if recording:
|
|
||||||
self._update_elapsed()
|
|
||||||
self.set_visible(True)
|
|
||||||
if self._timer_id is None:
|
|
||||||
self._timer_id = invoke_repeater(1000, self._update_elapsed)
|
|
||||||
else:
|
|
||||||
self.set_visible(False)
|
|
||||||
if self._timer_id is not None:
|
|
||||||
try:
|
|
||||||
GLib.source_remove(self._timer_id)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._timer_id = None
|
|
||||||
|
|
||||||
def _update_elapsed(self) -> bool:
|
|
||||||
started = self._service.started_at
|
|
||||||
if started is None:
|
|
||||||
self._elapsed.set_text("00:00")
|
|
||||||
return True
|
|
||||||
secs = int(time.monotonic() - started)
|
|
||||||
self._elapsed.set_text(f"{secs // 60:02d}:{secs % 60:02d}")
|
|
||||||
return True
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Screen-capture IPC subscriber.
|
||||||
|
|
||||||
|
Listens to fenster's `:capture` event, which is backed by riverfx's
|
||||||
|
`capture_sessions` events (river-window-management-v1 v5+). Fenster sends the
|
||||||
|
full aggregate on every change and replays it on subscribe:
|
||||||
|
|
||||||
|
{
|
||||||
|
"change": "active" | "inactive",
|
||||||
|
"outputs": [{"name": "DP-1", "sessions": 1}],
|
||||||
|
"windows": [{"id": 42, "app_id": "firefox", "title": "...", "sessions": 1}]
|
||||||
|
}
|
||||||
|
|
||||||
|
Create the singleton before the GLib main loop starts so the replayed initial
|
||||||
|
state is not missed.
|
||||||
|
"""
|
||||||
|
from fabric.core.service import Service, Signal
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from sims.services.fenster import get_i3_connection
|
||||||
|
|
||||||
|
_service: "CaptureService | None" = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureService(Service):
|
||||||
|
@Signal
|
||||||
|
def capture_changed(self) -> None:
|
||||||
|
"""Emitted when the set of captured outputs/windows changes."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._outputs: list[dict] = []
|
||||||
|
self._windows: list[dict] = []
|
||||||
|
i3 = get_i3_connection()
|
||||||
|
i3.connect("event::capture::active", self._on_event)
|
||||||
|
i3.connect("event::capture::inactive", self._on_event)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active(self) -> bool:
|
||||||
|
return bool(self._outputs or self._windows)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outputs(self) -> list[dict]:
|
||||||
|
"""Captured outputs: [{"name": str, "sessions": int}]."""
|
||||||
|
return list(self._outputs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def windows(self) -> list[dict]:
|
||||||
|
"""Captured windows: [{"id", "app_id", "title", "sessions"}]."""
|
||||||
|
return list(self._windows)
|
||||||
|
|
||||||
|
def _on_event(self, _i3, event):
|
||||||
|
outputs = event.data.get("outputs")
|
||||||
|
windows = event.data.get("windows")
|
||||||
|
if not isinstance(outputs, list) or not isinstance(windows, list):
|
||||||
|
logger.warning(f"[Capture] unexpected event payload: {event.data!r}")
|
||||||
|
return
|
||||||
|
if outputs == self._outputs and windows == self._windows:
|
||||||
|
return
|
||||||
|
self._outputs = outputs
|
||||||
|
self._windows = windows
|
||||||
|
self.capture_changed()
|
||||||
|
|
||||||
|
|
||||||
|
def get_capture_service() -> CaptureService:
|
||||||
|
global _service
|
||||||
|
if _service is None:
|
||||||
|
_service = CaptureService()
|
||||||
|
return _service
|
||||||
@@ -71,6 +71,7 @@ class I3MessageType(IntEnum):
|
|||||||
INPUT_EVENT = 0x80000015
|
INPUT_EVENT = 0x80000015
|
||||||
# fenster extensions (event id 100+)
|
# fenster extensions (event id 100+)
|
||||||
SMART_CORNERS_EVENT = 0x80000064
|
SMART_CORNERS_EVENT = 0x80000064
|
||||||
|
CAPTURE_EVENT = 0x80000065
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|||||||
+5
-5
@@ -127,7 +127,7 @@
|
|||||||
color: var(--blue);
|
color: var(--blue);
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenrec-widget {
|
#capture-widget {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
@@ -135,14 +135,14 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenrec-dot {
|
#capture-dot {
|
||||||
color: #ff4444;
|
color: var(--red);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#screenrec-elapsed {
|
#capture-summary {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: monospace;
|
margin-right: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
tooltip {
|
tooltip {
|
||||||
|
|||||||
Reference in New Issue
Block a user