Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Example:
"fullscreen_text": "Camera monitor",
"settings": {
"auto_start": false,
"fullscreen": false,
"render_fps": 20,
"rtsp_transport": "tcp",
"vlc_network_caching_ms": 400,
Expand All @@ -128,6 +129,8 @@ credentials.

`auto_start` defaults to `false`, so a fresh checkout never contacts a camera
until one is selected. Set it to `true` to start the first feed automatically.
Set `fullscreen` to `true` to open either viewer in fullscreen mode. `Escape`
still returns the application to windowed mode.

## Controls

Expand Down
1 change: 1 addition & 0 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"fullscreen_text": "rtsp-viewer",
"settings": {
"auto_start": false,
"fullscreen": false,
"render_fps": 20,
"rtsp_transport": "tcp",
"vlc_network_caching_ms": 400,
Expand Down
1 change: 1 addition & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"additionalProperties": false,
"properties": {
"auto_start": { "type": "boolean", "default": false },
"fullscreen": { "type": "boolean", "default": false },
"render_fps": { "type": "integer", "minimum": 1, "maximum": 60 },
"rtsp_transport": { "enum": ["tcp", "udp", "auto"] },
"vlc_network_caching_ms": {
Expand Down
42 changes: 22 additions & 20 deletions rtsp-viewer-vlc/rtsp-viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ def __init__(self, root, config, backend_mode="auto", vlc_command=None):
self.streams = config["feeds"]

self.setup_ui()
self.set_fullscreen(self.settings["fullscreen"])
self.root.update_idletasks()
window_id = self.video_frame.winfo_id()
windowing_system = str(self.root.tk.call("tk", "windowingsystem"))
Expand Down Expand Up @@ -317,17 +318,10 @@ def setup_ui(self):
(0, 0), window=self.scrollable_frame, anchor="nw"
)
self.canvas.configure(yscrollcommand=self.scrollbar.set)
self.canvas.bind(
"<Configure>",
lambda event: self.canvas.itemconfig(
self.canvas_window, width=event.width
),
)
self.canvas.bind("<Configure>", self._resize_sidebar)
self.scrollable_frame.bind(
"<Configure>",
lambda _event: self.canvas.configure(
scrollregion=self.canvas.bbox("all")
),
lambda _event: self._update_sidebar_scrollregion(),
)
self.canvas.bind_all("<MouseWheel>", self._on_mousewheel)
self.canvas.bind_all("<Button-4>", self._on_mousewheel_linux)
Expand All @@ -351,14 +345,6 @@ def setup_ui(self):

self.control_bar = tk.Frame(self.main_content, bg="#151515", height=35)
self.control_bar.pack(side="bottom", fill="x")
tk.Button(
self.control_bar,
text="1x1",
bg="#2a2a2a",
fg="white",
relief="flat",
padx=12,
).pack(side="left", padx=4, pady=4)
self.status_label = tk.Label(
self.control_bar, text="Starting VLC…", bg="#151515", fg="#888"
)
Expand Down Expand Up @@ -431,6 +417,19 @@ def toggle_sidebar(self):
self.toggle_btn.config(text="◀")
self.sidebar_visible = not self.sidebar_visible

def _resize_sidebar(self, event):
self.canvas.itemconfig(self.canvas_window, width=event.width)
self._update_sidebar_scrollregion()

def _update_sidebar_scrollregion(self):
"""Keep short feed lists pinned to the top of the sidebar."""
bbox = self.canvas.bbox("all")
if bbox is None:
return
width = max(bbox[2], self.canvas.winfo_width())
height = max(bbox[3], self.canvas.winfo_height())
self.canvas.configure(scrollregion=(0, 0, width, height))

def _on_mousewheel(self, event):
if self.sidebar_visible:
self.canvas.yview_scroll(int(-(event.delta / 120)), "units")
Expand All @@ -446,9 +445,12 @@ def universal_key_handler(self, event):
self.exit_fullscreen()

def toggle_fullscreen(self):
self.fullscreen = not self.fullscreen
self.root.attributes("-fullscreen", self.fullscreen)
if self.fullscreen:
self.set_fullscreen(not self.fullscreen)

def set_fullscreen(self, enabled):
self.fullscreen = enabled
self.root.attributes("-fullscreen", enabled)
if enabled:
self.fullscreen_bar.pack(side="top", fill="x", before=self.video_area)
else:
self.fullscreen_bar.pack_forget()
Expand Down
30 changes: 22 additions & 8 deletions rtsp-viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ def __init__(self, root, config):
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "|".join(options)

self.setup_ui()
self.set_fullscreen(self.settings["fullscreen"])

# ---------------- Global Key Bindings ----------------
self.root.bind_all("<Key>", self.universal_key_handler)
Expand Down Expand Up @@ -199,14 +200,11 @@ def setup_ui(self):
self.canvas.bind_all("<Button-4>", self._on_mousewheel_linux)
self.canvas.bind_all("<Button-5>", self._on_mousewheel_linux)

self.canvas.bind(
"<Configure>",
lambda e: self.canvas.itemconfig(self.canvas_window, width=e.width)
)
self.canvas.bind("<Configure>", self._resize_sidebar)

self.scrollable_frame.bind(
"<Configure>",
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))
lambda _event: self._update_sidebar_scrollregion()
)

self.canvas_window = self.canvas.create_window(
Expand Down Expand Up @@ -279,6 +277,19 @@ def setup_ui(self):
# ---------------------------------------------------------
# SIDEBAR CONTROL
# ---------------------------------------------------------
def _resize_sidebar(self, event):
self.canvas.itemconfig(self.canvas_window, width=event.width)
self._update_sidebar_scrollregion()

def _update_sidebar_scrollregion(self):
"""Keep short feed lists pinned to the top of the sidebar."""
bbox = self.canvas.bbox("all")
if bbox is None:
return
width = max(bbox[2], self.canvas.winfo_width())
height = max(bbox[3], self.canvas.winfo_height())
self.canvas.configure(scrollregion=(0, 0, width, height))

def toggle_sidebar(self):
if self.sidebar_visible:
self.sidebar_container.config(width=18)
Expand Down Expand Up @@ -506,10 +517,13 @@ def universal_key_handler(self, event):
# WINDOW CONTROL
# ---------------------------------------------------------
def toggle_fullscreen(self):
self.fullscreen = not self.fullscreen
self.root.attributes("-fullscreen", self.fullscreen)
self.set_fullscreen(not self.fullscreen)

if self.fullscreen:
def set_fullscreen(self, enabled):
self.fullscreen = enabled
self.root.attributes("-fullscreen", enabled)

if enabled:
self.fullscreen_bar.pack(side="top", fill="x", before=self.video_area)
else:
self.fullscreen_bar.pack_forget()
Expand Down
3 changes: 2 additions & 1 deletion rtsp_viewer_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

DEFAULT_SETTINGS = {
"auto_start": False,
"fullscreen": False,
"render_fps": 20,
"rtsp_transport": "tcp",
"vlc_network_caching_ms": 400,
Expand Down Expand Up @@ -104,7 +105,7 @@ def load_config(explicit_path: str | Path | None = None) -> tuple[Path, dict]:
if transport not in {"tcp", "udp", "auto"}:
raise ConfigError("rtsp_transport must be 'tcp', 'udp', or 'auto'.")
settings["rtsp_transport"] = transport
for key in ("auto_start", "vlc_disable_audio"):
for key in ("auto_start", "fullscreen", "vlc_disable_audio"):
if not isinstance(settings[key], bool):
raise ConfigError(f"{key} must be true or false.")

Expand Down
15 changes: 15 additions & 0 deletions tests/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,23 @@ def test_loads_defaults(self):
_, config = load_config(path)
self.assertEqual(config["settings"]["render_fps"], 20)
self.assertFalse(config["settings"]["auto_start"])
self.assertFalse(config["settings"]["fullscreen"])
self.assertTrue(config["settings"]["vlc_disable_audio"])

def test_loads_fullscreen_setting(self):
path = self.write_config(
{"settings": {"fullscreen": True}, "feeds": []}
)
_, config = load_config(path)
self.assertTrue(config["settings"]["fullscreen"])

def test_rejects_non_boolean_fullscreen_setting(self):
path = self.write_config(
{"settings": {"fullscreen": "yes"}, "feeds": []}
)
with self.assertRaisesRegex(ConfigError, "fullscreen must be true or false"):
load_config(path)

def test_rejects_duplicate_hotkeys(self):
path = self.write_config(
{
Expand Down
Loading