diff --git a/README.md b/README.md index a8b4a23..36420aa 100644 --- a/README.md +++ b/README.md @@ -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, @@ -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 diff --git a/config.example.json b/config.example.json index 262fef5..9b4b5bc 100644 --- a/config.example.json +++ b/config.example.json @@ -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, diff --git a/config.schema.json b/config.schema.json index 4f3c75e..5b19d29 100644 --- a/config.schema.json +++ b/config.schema.json @@ -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": { diff --git a/rtsp-viewer-vlc/rtsp-viewer.py b/rtsp-viewer-vlc/rtsp-viewer.py index 7cd4a3c..e682baa 100644 --- a/rtsp-viewer-vlc/rtsp-viewer.py +++ b/rtsp-viewer-vlc/rtsp-viewer.py @@ -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")) @@ -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( - "", - lambda event: self.canvas.itemconfig( - self.canvas_window, width=event.width - ), - ) + self.canvas.bind("", self._resize_sidebar) self.scrollable_frame.bind( "", - lambda _event: self.canvas.configure( - scrollregion=self.canvas.bbox("all") - ), + lambda _event: self._update_sidebar_scrollregion(), ) self.canvas.bind_all("", self._on_mousewheel) self.canvas.bind_all("", self._on_mousewheel_linux) @@ -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" ) @@ -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") @@ -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() diff --git a/rtsp-viewer.py b/rtsp-viewer.py index 8e027dd..6b9e1de 100644 --- a/rtsp-viewer.py +++ b/rtsp-viewer.py @@ -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("", self.universal_key_handler) @@ -199,14 +200,11 @@ def setup_ui(self): self.canvas.bind_all("", self._on_mousewheel_linux) self.canvas.bind_all("", self._on_mousewheel_linux) - self.canvas.bind( - "", - lambda e: self.canvas.itemconfig(self.canvas_window, width=e.width) - ) + self.canvas.bind("", self._resize_sidebar) self.scrollable_frame.bind( "", - lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all")) + lambda _event: self._update_sidebar_scrollregion() ) self.canvas_window = self.canvas.create_window( @@ -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) @@ -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() diff --git a/rtsp_viewer_common.py b/rtsp_viewer_common.py index 137c663..0e68db9 100644 --- a/rtsp_viewer_common.py +++ b/rtsp_viewer_common.py @@ -19,6 +19,7 @@ DEFAULT_SETTINGS = { "auto_start": False, + "fullscreen": False, "render_fps": 20, "rtsp_transport": "tcp", "vlc_network_caching_ms": 400, @@ -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.") diff --git a/tests/test_common.py b/tests/test_common.py index 9da1060..a5e52a1 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -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( {