I", count) + pcm, "test")
+ count += 1
+
+ feed(recorder, 3, packet)
+ data = samples(finish(recorder))
+ assert amplitude(data, 660) > amplitude(data, 880) * 30
+
+
+def test_silent_browser_does_not_block_video_eos(recorder):
+ recorder.start(
+ None,
+ audio_sources=["phone_browser"],
+ active_audio_sources=["phone_browser"],
+ external_audio={"phone_browser": None},
+ )
+ feed(recorder)
+ finish(recorder)
+
+
+def test_external_capture_excludes_other_playback(recorder):
+ players = []
+ try:
+ for frequency in (440, 880):
+ players.append(
+ subprocess.Popen(
+ [
+ "gst-launch-1.0",
+ "-q",
+ "audiotestsrc",
+ "is-live=true",
+ f"freq={frequency}",
+ "!",
+ "audioconvert",
+ "!",
+ "pulsesink",
+ ],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ )
+ deadline = time.monotonic() + 5
+ while AudioMonitor._find_sink_input_by_pid(players[0].pid) is None:
+ assert time.monotonic() < deadline
+ time.sleep(0.05)
+ recorder.start(
+ None,
+ audio_sources=["android"],
+ active_audio_sources=["android"],
+ external_audio={"android": players[0].pid},
+ )
+ feed(recorder, 3)
+ data = samples(finish(recorder))
+ assert amplitude(data, 440) > amplitude(data, 880) * 30
+ index = AudioMonitor._find_sink_input_by_pid(players[0].pid)
+ subprocess.run(
+ ["pactl", "--", "set-sink-input-volume", str(index), "-12dB"],
+ check=True,
+ timeout=5,
+ )
+ recorder.start(
+ None,
+ audio_sources=["android"],
+ active_audio_sources=["android"],
+ source_volumes={"android": 0.25},
+ external_audio={"android": players[0].pid},
+ )
+ feed(recorder, 3)
+ quieter = samples(finish(recorder))
+ ratio = np.sqrt(np.mean(quieter**2) / np.mean(data**2))
+ assert 0.18 < ratio < 0.32, ratio
+ assert all(
+ process.poll() is not None for process, _thread in recorder._audio_processes
+ )
+ finally:
+ for process in players:
+ process.terminate()
+ process.wait(timeout=5)
+
+
+def test_storage_error_is_not_reported_as_saved(recorder):
+ outcomes = []
+ recorder.connect(
+ "finalized", lambda _r, _path, ok, error: outcomes.append((ok, error))
+ )
+ recorder.start(None, record_audio=False)
+ feed(recorder)
+ with patch.object(
+ os, "fsync", side_effect=OSError(errno.EIO, "simulated disk error")
+ ):
+ recorder.stop()
+ assert recorder.wait_finalize(20)
+ context = GLib.MainContext.default()
+ while context.pending():
+ context.iteration(False)
+ assert recorder.state == "error"
+ assert outcomes and outcomes[-1][0] is False
+ assert "simulated disk error" in outcomes[-1][1]
+ assert Path(recorder.output_path).stat().st_size > 0
+
+
+def test_initial_encoder_delay_does_not_duplicate_video_timestamps(recorder):
+ recorder.start(None, record_audio=False)
+ feed(recorder)
+ path = finish(recorder)
+ frames = json.loads(
+ subprocess.check_output(
+ [
+ "ffprobe",
+ "-v",
+ "error",
+ "-select_streams",
+ "v",
+ "-show_frames",
+ "-show_entries",
+ "frame=pts_time",
+ "-of",
+ "json",
+ path,
+ ],
+ timeout=10,
+ )
+ )["frames"]
+ timestamps = [float(frame["pts_time"]) for frame in frames]
+ assert len(timestamps) > 10
+ assert all(b > a for a, b in pairwise(timestamps))
diff --git a/tests/test_ui_dogtail.py b/tests/test_ui_dogtail.py
index 61853b3..c16173d 100644
--- a/tests/test_ui_dogtail.py
+++ b/tests/test_ui_dogtail.py
@@ -1,64 +1,11 @@
#!/usr/bin/env python3
-"""
-E2E UI Test usando dogtail para validar o GTK Main Thread
-O app `bigcam` deve estar em execução (ou o script o iniciará).
-"""
-
-import sys
-import time
-import subprocess
+"""Explicit graphical entry point; missing accessibility dependencies are failures."""
import os
+from pathlib import Path
+import subprocess
+import sys
-try:
- from dogtail.tree import root
- from dogtail.utils import run
-except ImportError:
- print("Skipping Dogtail test. 'python3-dogtail' is not installed.")
- sys.exit(0)
-
-def test_ui():
- print("Iniciando bigcam para teste E2E...")
- env = os.environ.copy()
- # Ensure AT-SPI is enabled
- env["GTK_A11Y"] = "none" # Actually we need accessibility, maybe default is fine or GTK_MODULES=gail:atk-bridge
-
- app_process = subprocess.Popen(
- [sys.executable, "-m", "bigcam.main"],
- cwd=os.path.abspath(os.path.join(os.path.dirname(__file__), "../usr/share/biglinux/bigcam")),
- env=env,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL
- )
-
- try:
- # Aguardar o aplicativo registrar no DBus/AT-SPI
- time.sleep(3)
-
- # Encontrar o app na árvore de acessibilidade
- bigcam_app = root.application("bigcam")
- print("App bigcam encontrado!")
-
- # Como o aplicativo usa Adwaita/GTK4, muitos botões não tem texto mas sim icones/tooltips
- # Vamos apenas iterar pelas tabs ou botões visíveis para garantir que a UI não travou.
- buttons = bigcam_app.findChildren(lambda n: n.roleName == 'push button')
- print(f"Encontrados {len(buttons)} botões.")
-
- for i, btn in enumerate(buttons[:5]):
- try:
- print(f"Clicando botão: {btn.name or 'Sem Nome'}")
- btn.click()
- time.sleep(0.5)
- except Exception as e:
- print(f"Aviso ao clicar no botão {i}: {e}")
-
- print("Teste UI finalizado com sucesso. Zero deadlocks.")
-
- except Exception as e:
- print(f"Erro no teste UI: {e}")
- sys.exit(1)
- finally:
- app_process.terminate()
- app_process.wait()
-
-if __name__ == "__main__":
- test_ui()
+if __name__=="__main__":
+ if not os.environ.get("BIGCAM_TEST_RESULTS"):
+ raise SystemExit("Set BIGCAM_TEST_RESULTS and run as a normal user. This test creates a private display.")
+ raise SystemExit(subprocess.call(["bash",str(Path(__file__).parent/"integration/session.sh")]))
diff --git a/usr/lib/bigcam/virtual-camera-helper b/usr/lib/bigcam/virtual-camera-helper
new file mode 100755
index 0000000..6714bf7
--- /dev/null
+++ b/usr/lib/bigcam/virtual-camera-helper
@@ -0,0 +1,180 @@
+#!/usr/bin/python3 -I
+"""Polkit-authorized, fixed-operation loopback helper. Never accepts commands.
+
+Only devices recorded for the invoking UID and session can be removed. A root-
+owned ledger is kept in /run (lost on reboot along with the kernel devices).
+Unregistered loopbacks are deliberately never reused, removed or reconfigured.
+"""
+from __future__ import annotations
+
+from contextlib import contextmanager
+import fcntl
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import stat
+import subprocess
+import sys
+import tempfile
+
+STATE = Path("/run/bigcam")
+SAFE_PATH = "/usr/sbin:/usr/bin:/sbin:/bin"
+SESSION = re.compile(r"[0-9a-f]{32}\Z")
+DEVICE = re.compile(r"/dev/video([0-9]{1,3})\Z")
+MAX_PER_USER = 8
+
+
+def validate(arguments: list[str]) -> tuple[str, str, str]:
+ if arguments == ["load"]:
+ return "load", "", ""
+ if len(arguments) != 3 or arguments[0] not in {"create", "delete"}:
+ raise ValueError("Usage: helper load | create SESSION LABEL | delete SESSION DEVICE")
+ action, session, value = arguments
+ if not SESSION.fullmatch(session):
+ raise ValueError("Invalid session identifier")
+ if action == "create":
+ if not value or len(value.encode("utf-8")) > 31 or any(not (c.isalnum() or c in " ._-") for c in value):
+ raise ValueError("Invalid device label (maximum 31 UTF-8 bytes)")
+ elif not DEVICE.fullmatch(value) or not 20 <= int(DEVICE.fullmatch(value)[1]) <= 255:
+ raise ValueError("Invalid device path")
+ return action, session, value
+
+
+def run(program: str, *arguments: str) -> subprocess.CompletedProcess:
+ binary = shutil.which(program, path=SAFE_PATH)
+ if binary is None:
+ raise RuntimeError(f"Required executable not found: {program}")
+ return subprocess.run([binary, *arguments], check=True, capture_output=True,
+ text=True, timeout=20, env={"PATH": SAFE_PATH, "LC_ALL": "C"},
+ stdin=subprocess.DEVNULL)
+
+
+@contextmanager
+def ledger():
+ STATE.mkdir(mode=0o700, exist_ok=True)
+ st = STATE.lstat()
+ if not stat.S_ISDIR(st.st_mode) or st.st_uid != 0 or st.st_mode & 0o077:
+ raise PermissionError("Unsafe loopback ledger directory")
+ fd = os.open(STATE / "lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
+ try:
+ fcntl.flock(fd, fcntl.LOCK_EX)
+ path = STATE / "devices.json"
+ if path.is_symlink():
+ raise PermissionError("Unsafe loopback ledger file")
+ data = json.loads(path.read_text()) if path.exists() else {}
+ if not isinstance(data, dict):
+ raise ValueError("Invalid loopback ledger")
+ yield data
+ temporary_fd, temporary = tempfile.mkstemp(dir=STATE, prefix=".ledger-")
+ try:
+ with os.fdopen(temporary_fd, "w", encoding="utf-8") as stream:
+ json.dump(data, stream, ensure_ascii=False)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path)
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+ finally:
+ os.close(fd)
+
+
+def load() -> None:
+ # No caller-controlled module, config path, environment or module options.
+ if not Path("/sys/module/v4l2loopback").exists():
+ run("modprobe", "--ignore-install", "--config", "/dev/null",
+ "v4l2loopback", "devices=0", "max_buffers=8")
+
+
+def current_label(device: str) -> str:
+ return (Path("/sys/class/video4linux") / Path(device).name / "name").read_text().strip()
+
+
+def process_identity(pid: int, uid: int) -> str | None:
+ try:
+ path = Path(f"/proc/{pid}")
+ if path.stat().st_uid != uid:
+ return None
+ # The command field may contain spaces or parentheses; starttime is
+ # field 22, counted after its final closing parenthesis.
+ return (path / "stat").read_text().rsplit(")", 1)[1].split()[19]
+ except FileNotFoundError:
+ return None
+
+
+def device_identity(device: str) -> int:
+ return (Path("/sys/class/video4linux") / Path(device).name).stat().st_ino
+
+
+def perform(action: str, session: str, value: str, uid: int) -> str:
+ with ledger() as records:
+ if action == "load":
+ load()
+ return ""
+ # Expunge only records whose devices no longer exist. Never remove a
+ # kernel device merely because it is unregistered in this process.
+ for device in list(records):
+ if not Path(device).exists():
+ del records[device]
+ if action == "delete":
+ record = records.get(value)
+ if record is None or record.get("uid") != uid or record.get("session") != session:
+ raise PermissionError("Device is not owned by this caller/session")
+ if (current_label(value) != record["label"]
+ or ("inode" in record and device_identity(value) != record["inode"])):
+ raise PermissionError("Device identity changed; refusing deletion")
+ run("v4l2loopback-ctl", "delete", value)
+ del records[value]
+ return ""
+ # Reclaim only our recorded devices from dead owners. A reused PID or
+ # video number must never confer ownership of a replacement resource.
+ for device, record in list(records.items()):
+ if record.get("uid") != uid or "pid" not in record:
+ continue
+ if process_identity(record["pid"], uid) == record["started"]:
+ continue
+ if (device_identity(device) != record["inode"]
+ or current_label(device) != record["label"]):
+ continue
+ run("v4l2loopback-ctl", "delete", device)
+ del records[device]
+ pid = os.getppid()
+ started = process_identity(pid, uid)
+ if started is None:
+ raise PermissionError("The requesting process is no longer owned by the caller")
+ if sum(record.get("uid") == uid for record in records.values()) >= MAX_PER_USER:
+ raise RuntimeError("Per-user loopback quota reached; administrator cleanup may be required")
+ load()
+ for number in range(20, 256):
+ device = f"/dev/video{number}"
+ if Path(device).exists() or device in records:
+ continue
+ run("v4l2loopback-ctl", "add", "-n", value, "-x", "1", "-b", "8", device)
+ # Persist only the specific device successfully created by this call.
+ records[device] = {"uid": uid, "session": session, "label": value,
+ "pid": pid, "started": started, "inode": device_identity(device)}
+ return device
+ raise RuntimeError("No free loopback device number")
+
+
+def main(argv: list[str] | None = None) -> int:
+ try:
+ action, session, value = validate(sys.argv[1:] if argv is None else argv)
+ if os.geteuid() != 0:
+ raise PermissionError("This helper must be authorized through Polkit")
+ caller = os.environ.get("PKEXEC_UID")
+ if caller is None or not caller.isascii() or not caller.isdigit():
+ raise PermissionError("Missing Polkit caller identity")
+ result = perform(action, session, value, int(caller))
+ if result:
+ print(result)
+ return 0
+ except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as exc:
+ print(f"BigCam virtual camera: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/usr/share/biglinux/bigcam/core/audio_monitor.py b/usr/share/biglinux/bigcam/core/audio_monitor.py
index cb6a531..f8b2892 100644
--- a/usr/share/biglinux/bigcam/core/audio_monitor.py
+++ b/usr/share/biglinux/bigcam/core/audio_monitor.py
@@ -2,19 +2,20 @@
from __future__ import annotations
+import json
import logging
import os
import re
import subprocess
-from utils.command_runner import SecureCommandRunner
import threading
from typing import Callable
import gi
+from utils.command_runner import SecureCommandRunner
gi.require_version("Gst", "1.0")
-from gi.repository import Gst, GLib, GObject
+from gi.repository import GLib, GObject, Gst
log = logging.getLogger(__name__)
@@ -166,7 +167,8 @@ def sources(self) -> list[tuple[str, str]]:
result = list(self._sources)
with self._ext_lock:
for name, info in self._external.items():
- result.append((name, info["label"]))
+ if not info["pid"] or info["index"] is not None:
+ result.append((name, info["label"]))
return result
@property
@@ -198,9 +200,7 @@ def active_source_names(self) -> list[str]:
@property
def all_source_names(self) -> list[str]:
"""Return PulseAudio device names of all detected sources."""
- result = [s[0] for s in self._sources]
- result.extend(self._external.keys())
- return result
+ return [name for name, _label in self.sources]
def toggle_source(self, source_name: str) -> None:
"""Start or stop playback of a given source."""
@@ -338,6 +338,7 @@ def _resolve_sink_input(self, name: str, pid: int) -> None:
self._pactl_volume_external(name, vol)
if self._muted or not src_active:
self._pactl_mute_external(name, True)
+ GLib.idle_add(self.emit, "sources-changed")
log.info("Resolved sink-input #%d for external source %s (pid %d)", idx, name, pid)
return
import time
@@ -369,87 +370,21 @@ def _find_sink_input_by_pid(pid: int) -> int | None:
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
- # --- Phase 1: check application.process.id in sink-inputs ----------
+ process_ids = {str(pid) for pid in pids_to_check}
try:
- result = SecureCommandRunner.run_safe(
- ["pactl", "list", "sink-inputs"],
- capture_output=True, text=True, timeout=5,
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return None
- if result.returncode != 0:
- return None
-
- # Parse sink-inputs: collect (index, client_id) pairs
- cur_index: int | None = None
- cur_client: int | None = None
- sink_inputs: list[tuple[int, int | None]] = []
-
- for line in result.stdout.splitlines():
- stripped = line.strip()
- if stripped.startswith("Sink Input #"):
- if cur_index is not None:
- sink_inputs.append((cur_index, cur_client))
- try:
- cur_index = int(stripped.split("#", 1)[1])
- except ValueError:
- cur_index = None
- cur_client = None
- elif stripped.startswith("Client:") and cur_index is not None:
- try:
- cur_client = int(stripped.split(":", 1)[1].strip())
- except ValueError:
- pass
- elif "application.process.id" in stripped and cur_index is not None:
- val = stripped.split("=", 1)[1].strip().strip('"')
- try:
- if int(val) in pids_to_check:
- return cur_index
- except ValueError:
- pass
- if cur_index is not None:
- sink_inputs.append((cur_index, cur_client))
-
- # --- Phase 2: check pipewire.sec.pid in clients --------------------
- client_ids = {c for _, c in sink_inputs if c is not None}
- if not client_ids:
- return None
-
- try:
- cl_result = SecureCommandRunner.run_safe(
- ["pactl", "list", "clients"],
- capture_output=True, text=True, timeout=5,
- )
- except (FileNotFoundError, subprocess.TimeoutExpired):
- return None
- if cl_result.returncode != 0:
- return None
-
- # Map client_id → PID (from pipewire.sec.pid or application.process.id)
- cl_id: int | None = None
- matching_clients: set[int] = set()
- for line in cl_result.stdout.splitlines():
- stripped = line.strip()
- if stripped.startswith("Client #"):
- try:
- cl_id = int(stripped.split("#", 1)[1])
- except ValueError:
- cl_id = None
- elif cl_id is not None and cl_id in client_ids:
- for key in ("pipewire.sec.pid", "application.process.id"):
- if key in stripped:
- val = stripped.split("=", 1)[1].strip().strip('"')
- try:
- if int(val) in pids_to_check:
- matching_clients.add(cl_id)
- except ValueError:
- pass
-
- # Return the first sink-input whose client matches
- for si_index, si_client in sink_inputs:
- if si_client in matching_clients:
- return si_index
-
+ inputs = json.loads(subprocess.check_output(
+ ["pactl", "-f", "json", "list", "sink-inputs"], text=True, timeout=5))
+ clients = json.loads(subprocess.check_output(
+ ["pactl", "-f", "json", "list", "clients"], text=True, timeout=5))
+ matching = {client["index"] for client in clients
+ if any(str(client.get("properties", {}).get(key)) in process_ids
+ for key in ("pipewire.sec.pid", "application.process.id"))}
+ for item in inputs:
+ if (str(item.get("properties", {}).get("application.process.id")) in process_ids
+ or item.get("client") in matching):
+ return item["index"]
+ except (OSError, ValueError, subprocess.SubprocessError):
+ log.warning("Could not query external playback streams")
return None
def _pactl_volume_external(self, name: str, value: float) -> None:
@@ -642,3 +577,9 @@ def _restart_source(self, source: str) -> bool:
self._stop_source(source)
self._start_source(source)
return GLib.SOURCE_REMOVE
+
+ @property
+ def external_recording_sources(self) -> dict[str, int | None]:
+ with self._ext_lock:
+ return {name: info["pid"] for name, info in self._external.items()
+ if not info["pid"] or info["index"] is not None}
diff --git a/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py b/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py
index 01a400d..324b8e2 100644
--- a/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py
+++ b/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py
@@ -15,6 +15,7 @@
from constants import BackendType, ControlCategory, ControlType, BASE_DIR
from core.camera_backend import CameraBackend, CameraControl, CameraInfo, VideoFormat
from utils.i18n import _
+from core.gphoto_session import GPhotoSession
log = logging.getLogger(__name__)
@@ -36,78 +37,9 @@ class GPhoto2Backend(CameraBackend):
def get_backend_type(self) -> BackendType:
return BackendType.GPHOTO2
- @staticmethod
- def _kill_gvfs() -> None:
- """Kill GVFS processes that interfere with gphoto2 USB access."""
- SecureCommandRunner.run_safe(
- ["systemctl", "--user", "stop", "gvfs-gphoto2-volume-monitor.service"],
- capture_output=True,
- timeout=5,
- )
- SecureCommandRunner.run_safe(
- ["systemctl", "--user", "mask", "gvfs-gphoto2-volume-monitor.service"],
- capture_output=True,
- timeout=5,
- )
- SecureCommandRunner.run_safe(
- ["pkill", "-9", "-f", "gvfs-gphoto2-volume-monitor"],
- capture_output=True,
- timeout=5,
- )
- SecureCommandRunner.run_safe(
- ["pkill", "-9", "-f", "gvfsd-gphoto2"],
- capture_output=True,
- timeout=5,
- )
- SecureCommandRunner.run_safe(
- ["gio", "mount", "-u", "gphoto2://"],
- capture_output=True,
- timeout=5,
- )
- @staticmethod
- def _release_usb_device(port: str) -> None:
- """Kill GVFS processes holding the USB device so gphoto2 can open it."""
- _GVFS_PATTERNS = ("gvfs", "gphoto")
- try:
- bus, dev = port.replace("usb:", "").split(",")
- usb_path = f"/dev/bus/usb/{bus}/{dev}"
- if not os.path.exists(usb_path):
- return
- result = SecureCommandRunner.run_safe(
- ["fuser", usb_path],
- capture_output=True,
- text=True,
- timeout=5,
- )
- pids = result.stdout.strip().split()
- killed = False
- for pid_str in pids:
- pid_str = pid_str.strip().rstrip(":")
- if not pid_str.isdigit():
- continue
- pid = int(pid_str)
- # Skip our own process
- if pid == os.getpid():
- continue
- try:
- cmdline_path = f"/proc/{pid}/cmdline"
- with open(cmdline_path) as f:
- cmdline = f.read().lower()
- log.debug("PID %d holding %s: %s", pid, usb_path, cmdline[:120])
- # Only kill GVFS-related processes, not arbitrary ones
- if any(p in cmdline for p in _GVFS_PATTERNS):
- os.kill(pid, signal.SIGKILL)
- log.debug("Killed GVFS PID %d", pid)
- killed = True
- else:
- log.info("PID %d on %s is not GVFS — skipping", pid, usb_path)
- except (ProcessLookupError, FileNotFoundError, PermissionError):
- pass
- if killed:
- time.sleep(0.5)
- except Exception:
- pass
+
+
@staticmethod
def _diagnose_usb(port: str) -> None:
@@ -257,16 +189,6 @@ def _has_remote_control(port: str) -> bool:
def detect_cameras(self) -> list[CameraInfo]:
cameras: list[CameraInfo] = []
try:
- # Kill GVFS to release the camera (skip if already streaming
- # to avoid disrupting an active session)
- if not self._streaming_active:
- SecureCommandRunner.run_safe(
- ["pkill", "-f", "gvfs-gphoto2-volume-monitor"],
- capture_output=True,
- timeout=5,
- )
- time.sleep(0.3)
-
# Retry up to 2 times in case GVFS hasn't released the device yet
max_attempts = 1 if self._streaming_active else 2
for attempt in range(max_attempts):
@@ -330,44 +252,9 @@ def detect_cameras(self) -> list[CameraInfo]:
@classmethod
def _refresh_port(cls, camera: CameraInfo) -> str:
- """Re-detect the current USB port for a camera (device number may change)."""
- old_port = camera.extra.get("port", camera.device_path)
- try:
- result = SecureCommandRunner.run_safe(
- ["gphoto2", "--auto-detect"],
- capture_output=True,
- text=True,
- timeout=10,
- )
- if result.returncode != 0:
- return old_port
-
- for line in result.stdout.strip().splitlines()[2:]:
- line = line.strip()
- if not line or "usb:" not in line:
- continue
- parts = line.split("usb:")
- if len(parts) < 2:
- continue
- name = parts[0].strip()
- port = "usb:" + parts[1].strip()
- # Match by camera model name
- if name and name in camera.name:
- if port != old_port:
- log.debug(f"Port changed: {old_port} -> {port}")
- # Update _active_streams key if camera was streaming
- with cls._streams_lock:
- if old_port in cls._active_streams:
- stream_info = cls._active_streams.pop(old_port)
- cls._active_streams[port] = stream_info
- log.debug(f"Updated _active_streams: {old_port} -> {port}")
- camera.extra["port"] = port
- camera.device_path = port
- camera.id = f"gphoto2:{port}"
- return port
- except Exception:
- pass
- return old_port
+ # A model label is not a device identifier. A disconnected camera must be
+ # rediscovered, not silently replaced by a second camera with the same name.
+ return camera.extra.get("port", camera.device_path)
# Keyword-to-category mapping for individual config names
_CONTROL_CATEGORY: dict[str, ControlCategory] = {
@@ -491,9 +378,8 @@ def get_controls(self, camera: CameraInfo) -> list[CameraControl]:
pass
# Ensure GVFS is dead and USB device is free
- self._kill_gvfs()
- self._release_usb_device(port)
-
+ # Other applications retain ownership of their USB sessions.
+ # Other applications retain ownership of their USB sessions.
# Diagnostic: check USB device accessibility
self._diagnose_usb(port)
@@ -503,8 +389,8 @@ def get_controls(self, camera: CameraInfo) -> list[CameraControl]:
if delay:
log.debug(f"get_controls: waiting {delay}s before retry...")
time.sleep(delay)
- self._kill_gvfs()
- self._release_usb_device(port)
+ # Other applications retain ownership of their USB sessions.
+ # Other applications retain ownership of their USB sessions.
# Re-diagnose after wait
self._diagnose_usb(port)
@@ -529,7 +415,7 @@ def get_controls(self, camera: CameraInfo) -> list[CameraControl]:
# Last resort: re-detect port and try once more
port = self._refresh_port(camera)
log.debug(f"get_controls fallback port={port}")
- self._release_usb_device(port)
+ # Other applications retain ownership of their USB sessions.
self._diagnose_usb(port)
result = SecureCommandRunner.run_safe(
["gphoto2", "--port", port, "--list-all-config"],
@@ -726,227 +612,61 @@ def get_gst_source(self, camera: CameraInfo, fmt: VideoFormat | None = None) ->
)
def start_streaming(self, camera: CameraInfo) -> bool:
- """Launch the gphoto2 streaming script (persistent session per camera)."""
- # Refresh USB port (device number may change after GVFS kill)
port = self._refresh_port(camera)
-
- # Release GVFS early so the abilities check can access the device
- self._kill_gvfs()
-
- # Fast check: does this camera actually support capture?
+ with self._streams_lock:
+ previous = self._active_streams.get(port)
+ if previous and previous["session"].running:
+ return True
+ self.stop_streaming(camera)
if not self._check_capture_support(port):
- log.warning(
- "Camera %s does not support capture (PTP driver limitation)",
- camera.name,
- )
camera.extra["capture_unsupported"] = True
return False
-
- # Quick check: does the camera expose remote-control PTP settings?
- # Cameras in MTP/basic-PTP mode lack capturesettings/imgsettings.
if not self._has_remote_control(port):
- log.warning(
- "Camera %s has no remote-control settings — likely in MTP "
- "mode (needs PC Remote or HDMI capture).",
- camera.name,
- )
camera.extra["ptp_streaming_error"] = True
return False
-
- self._streaming_active = True
- udp_port = str(camera.extra.get("udp_port", 5000))
-
- # If this camera is already streaming, just return success
- with self._streams_lock:
- if port in self._active_streams:
- log.debug(f"Camera {camera.name} already streaming on port {port}")
- return True
-
- # Release USB device before streaming (GVFS already killed above)
- self._release_usb_device(port)
-
- script = os.path.join(BASE_DIR, "script", "run_webcam_gphoto2.sh")
- if not os.path.isfile(script):
- script = os.path.join(BASE_DIR, "script", "run_webcam.sh")
- if not os.path.isfile(script):
- log.error("GPhoto2 streaming script not found: %s", script)
- return False
-
- if not os.access(script, os.X_OK):
- try:
- os.chmod(script, 0o755)
- except OSError:
- pass
-
- port_arg = port if port else ""
- # Do NOT let ffmpeg write directly to v4l2loopback — BigCam's
- # appsrc pipeline handles v4l2loopback output so that OpenCV
- # effects are applied to the virtual camera. Passing "none"
- # tells the script to stream only via UDP.
- v4l2_dev = "none"
- log.info(
- "Starting gphoto2 streaming: port=%s, udp=%s, v4l2_dev=%s",
- port_arg, udp_port, v4l2_dev,
- )
try:
- import tempfile
-
- with tempfile.TemporaryFile() as f:
- res = SecureCommandRunner.run_safe(
- [script, port_arg, udp_port, camera.name, v4l2_dev],
- stdout=f,
- stderr=subprocess.STDOUT,
- timeout=60,
- capture_output=False,
- )
- f.seek(0)
- raw = f.read()
- output = raw.decode("utf-8", errors="replace").strip()
- log.info("gphoto2 script output:\n%s", output)
-
- if res.returncode == 0:
- for line in output.split("\n"):
- if line.startswith("SUCCESS:"):
- dev = line.split("SUCCESS:")[1].strip()
- log.info("GPhoto2 streaming started on %s", dev)
- with self._streams_lock:
- self._active_streams[port] = {
- "udp_port": udp_port,
- "launch_port": port,
- "vcam_device": v4l2_dev,
- }
- return True
- log.info("GPhoto2 script exited 0 (no explicit SUCCESS)")
- with self._streams_lock:
- self._active_streams[port] = {
- "udp_port": udp_port,
- "launch_port": port,
- "vcam_device": v4l2_dev,
- }
- return True
-
- log.error("GPhoto2 script failed (code %d): %s", res.returncode, output)
- # Detect PTP-level failures (camera doesn't really support streaming)
- out_lower = output.lower()
- if any(kw in out_lower for kw in (
- "ptp general error", "ptp error", "ptp timeout",
- "0 quadros", "0 frames",
- "not valid", "não é válido",
- )):
- log.warning(
- "Camera %s failed with PTP errors — likely lacks "
- "PC Remote mode for live streaming",
- camera.name,
- )
- camera.extra["ptp_streaming_error"] = True
- self._streaming_active = False
- return False
- except Exception as exc:
- log.error("Failed to start gphoto2 streaming: %s", exc)
- self._streaming_active = False
+ session = GPhotoSession(port, int(camera.extra.get("udp_port", 5000)))
+ with self._streams_lock:
+ # Serialize producer startup for this backend; no detached shell.
+ previous = self._active_streams.get(port)
+ if previous and previous["session"].running:
+ return True
+ if not session.start():
+ session.stop()
+ return False
+ self._active_streams[port] = {"session": session, "launch_port": port,
+ "udp_port": str(session.udp_port), "vcam_device": "none"}
+ self._streaming_active = True
+ return True
+ except (OSError, ValueError, subprocess.SubprocessError):
+ log.exception("Could not start the selected DSLR producer")
return False
def stop_streaming(self, camera: CameraInfo | None = None) -> None:
- """Stop gphoto2/ffmpeg processes for a specific camera, or all if None."""
- self._streaming_active = False
- try:
- if camera:
- port = camera.extra.get("port", camera.device_path)
- udp_port = str(camera.extra.get("udp_port", 5000))
- with self._streams_lock:
- stream_info = self._active_streams.pop(port, None)
- launch_port = stream_info["launch_port"] if stream_info else port
-
- safe_lp = re.escape(launch_port)
- safe_port = re.escape(port)
- safe_udp = re.escape(udp_port)
-
- # Graceful SIGTERM first
- SecureCommandRunner.run_safe(
- ["pkill", "-f", f"gphoto2.*--port {safe_lp}"],
- capture_output=True,
- timeout=5,
- )
- if launch_port != port:
- SecureCommandRunner.run_safe(
- ["pkill", "-f", f"gphoto2.*--port {safe_port}"],
- capture_output=True,
- timeout=5,
- )
- SecureCommandRunner.run_safe(
- ["pkill", "-f", f"ffmpeg.*udp://127\\.0\\.0\\.1:{safe_udp}"],
- capture_output=True,
- timeout=5,
- )
- time.sleep(2)
- # Force-kill survivors
- SecureCommandRunner.run_safe(
- ["pkill", "-9", "-f", f"gphoto2.*--port {safe_lp}"],
- capture_output=True,
- timeout=5,
- )
- if launch_port != port:
- SecureCommandRunner.run_safe(
- ["pkill", "-9", "-f", f"gphoto2.*--port {safe_port}"],
- capture_output=True,
- timeout=5,
- )
- SecureCommandRunner.run_safe(
- ["pkill", "-9", "-f", f"ffmpeg.*udp://127\\.0\\.0\\.1:{safe_udp}"],
- capture_output=True,
- timeout=5,
- )
+ """Stop only Popen objects created by this backend instance/session."""
+ with self._streams_lock:
+ if camera is None:
+ sessions = list(self._active_streams.values())
+ self._active_streams.clear()
else:
- with self._streams_lock:
- self._active_streams.clear()
- SecureCommandRunner.run_safe(["pkill", "-f", "gphoto2 --"], capture_output=True, timeout=5)
- time.sleep(1)
- SecureCommandRunner.run_safe(["pkill", "-9", "-f", "gphoto2 --"], capture_output=True, timeout=5)
- SecureCommandRunner.run_safe(
- ["pkill", "-9", "-f", "ffmpeg.*mpegts"], capture_output=True, timeout=5
- )
- SecureCommandRunner.run_safe(
- ["pkill", "-9", "-f", "ffmpeg.*v4l2"], capture_output=True, timeout=5
- )
- except Exception:
- log.warning("stop_streaming cleanup error", exc_info=True)
- self._streaming_process = None
-
- # Kill GVFS immediately after stopping — prevents it from re-grabbing cameras
- self._kill_gvfs()
- time.sleep(1)
+ entry = self._active_streams.pop(camera.extra.get("port", camera.device_path), None)
+ sessions = [entry] if entry else []
+ self._streaming_active = bool(self._active_streams)
+ for entry in sessions:
+ try:
+ entry["session"].stop()
+ except (OSError, subprocess.SubprocessError):
+ log.exception("Could not stop an owned DSLR producer")
def needs_streaming_setup(self) -> bool:
"""GPhoto2 requires an external streaming process."""
return True
def is_camera_streaming(self, camera: CameraInfo) -> bool:
- """Check if a specific camera already has an active streaming session."""
port = camera.extra.get("port", camera.device_path)
with self._streams_lock:
- if port not in self._active_streams:
- return False
- stream_info = self._active_streams[port].copy()
- # Verify the process is actually alive using the launch port
- launch_port = stream_info.get("launch_port", port)
- result = SecureCommandRunner.run_safe(
- ["pgrep", "-f", f"gphoto2.*--port {launch_port}"],
- capture_output=True,
- )
- if result.returncode != 0:
- # Also try current port (in case it matches)
- if launch_port != port:
- result = SecureCommandRunner.run_safe(
- ["pgrep", "-f", f"gphoto2.*--port {port}"],
- capture_output=True,
- )
- if result.returncode == 0:
- return True
- # Process died — clean up
- with self._streams_lock:
- self._active_streams.pop(port, None)
- return False
- return True
+ entry = self._active_streams.get(port)
+ return bool(entry and entry["session"].running)
# -- photo ---------------------------------------------------------------
@@ -955,63 +675,16 @@ def can_capture_photo(self) -> bool:
def capture_photo(self, camera: CameraInfo, output_path: str) -> bool:
port = camera.extra.get("port", camera.device_path)
- camera_arg = ["--port", port] if port else []
- debug_log = "/tmp/gphoto2_capture_debug.log"
-
- for attempt in range(2):
- try:
- self._kill_gvfs()
- if attempt > 0:
- self._release_usb_device(port)
- time.sleep(2)
-
- log.info(
- "capture_photo attempt %d: starting gphoto2 on port %s",
- attempt + 1, port,
- )
- result = SecureCommandRunner.run_safe(
- [
- "gphoto2",
- *camera_arg,
- "--debug-logfile", debug_log,
- "--capture-image-and-download",
- "--filename",
- output_path,
- "--force-overwrite",
- "--keep",
- ],
- capture_output=True,
- text=True,
- timeout=30,
- )
- log.info(
- "capture_photo attempt %d: rc=%d stdout=%s stderr=%s",
- attempt + 1, result.returncode,
- result.stdout[:200] if result.stdout else "",
- result.stderr[:200] if result.stderr else "",
- )
- if result.returncode == 0 and os.path.isfile(output_path):
- return True
- except subprocess.TimeoutExpired as exc:
- log.warning("capture_photo attempt %d timed out", attempt + 1)
- # Log debug output from gphoto2 to understand where it hung
- try:
- with open(debug_log) as f:
- lines = f.readlines()
- tail = "".join(lines[-20:]) if lines else "(empty)"
- log.warning("gphoto2 debug log tail:\n%s", tail)
- except Exception:
- pass
- # Kill the timed-out process
- if port:
- safe_port = re.escape(port)
- SecureCommandRunner.run_safe(
- ["pkill", "-9", "-f", f"gphoto2.*{safe_port}"],
- capture_output=True,
- )
- time.sleep(2)
- except Exception as exc:
- log.warning("capture_photo attempt %d failed: %s", attempt + 1, exc)
- if attempt == 0:
- time.sleep(1)
- return False
+ if not re.fullmatch(r"usb:[0-9]{1,3},[0-9]{1,3}", port):
+ return False
+ self.stop_streaming(camera)
+ try:
+ result = SecureCommandRunner.run_safe(
+ ["gphoto2", "--port", port, "--capture-image-and-download", "--filename", output_path,
+ "--force-overwrite", "--keep"], capture_output=True, text=True, timeout=60)
+ # The caller reserves a unique path. Preserve native bytes and metadata.
+ return result.returncode == 0 and os.path.isfile(output_path) and os.path.getsize(output_path) > 0
+ except (OSError, subprocess.SubprocessError):
+ # subprocess.run kills/reaps its own child on timeout; never pkill a name.
+ log.warning("Native photo capture failed for the selected camera")
+ return False
diff --git a/usr/share/biglinux/bigcam/core/backends/ip_backend.py b/usr/share/biglinux/bigcam/core/backends/ip_backend.py
index d9295c8..b381384 100644
--- a/usr/share/biglinux/bigcam/core/backends/ip_backend.py
+++ b/usr/share/biglinux/bigcam/core/backends/ip_backend.py
@@ -4,10 +4,17 @@
import logging
import os
-import subprocess
from typing import Any
+from urllib.parse import urlsplit
+
+import gi
+
+gi.require_version("Gst", "1.0")
from constants import BackendType
+from gi.repository import GLib, Gst
+from utils.urls import camera_url, camera_url_id, gst_quote, public_camera_name
+
from core.camera_backend import CameraBackend, CameraControl, CameraInfo, VideoFormat
log = logging.getLogger(__name__)
@@ -30,23 +37,19 @@ def detect_cameras(self) -> list[CameraInfo]:
return []
def cameras_from_urls(self, entries: list[dict[str, str]]) -> list[CameraInfo]:
- """Build CameraInfo list from user-saved [{"name": ..., "url": ...}]."""
- cameras: list[CameraInfo] = []
+ cameras = []
for entry in entries:
- url = entry.get("url", "")
- name = entry.get("name", url)
- if not url:
+ try:
+ url = camera_url(entry["url"])
+ name = entry.get("name") or public_camera_name(url)
+ # Legacy configurations commonly stored the credential-bearing URL as a name.
+ if name == entry["url"] or "://" in name:
+ name = public_camera_name(url)
+ except (KeyError, TypeError, ValueError):
+ log.warning("Ignoring an invalid camera configuration")
continue
- cameras.append(
- CameraInfo(
- id=f"ip:{url}",
- name=name,
- backend=BackendType.IP,
- device_path=url,
- capabilities=["video"],
- extra={"url": url},
- )
- )
+ cameras.append(CameraInfo(id=camera_url_id(url), name=name, backend=BackendType.IP,
+ device_path=url, capabilities=["video"], extra={"url": url}))
return cameras
# -- controls (none for basic IP) ----------------------------------------
@@ -60,42 +63,42 @@ def set_control(self, camera: CameraInfo, control_id: str, value: Any) -> bool:
# -- gstreamer -----------------------------------------------------------
def get_gst_source(self, camera: CameraInfo, fmt: VideoFormat | None = None) -> str:
- url = camera.extra.get("url", camera.device_path)
- if url.startswith("rtsp://"):
- return f'rtspsrc location="{url}" latency=300 ! decodebin ! videoconvert'
- # HTTP / MJPEG stream
- return f'souphttpsrc location="{url}" ! decodebin ! videoconvert'
+ url = camera_url(camera.extra.get("url", camera.device_path))
+ if urlsplit(url).scheme in {"rtsp", "rtsps"}:
+ return f"rtspsrc location={gst_quote(url)} latency=150 ! decodebin ! videoconvert"
+ return f"souphttpsrc location={gst_quote(url)} ! decodebin ! videoconvert"
# -- photo ---------------------------------------------------------------
def can_capture_photo(self) -> bool:
return True
+ @staticmethod
+ def prepare_pipeline(pipeline: Gst.Pipeline) -> None:
+ def element_added(_pipeline, _subbin, element):
+ factory = element.get_factory()
+ if factory and factory.get_name() == "multipartdemux":
+ # MJPEG cameras keep a single image stream open indefinitely.
+ # Let decodebin expose that pad without waiting for another MIME type.
+ element.set_property("single-stream", True)
+ pipeline.connect("deep-element-added", element_added)
+
def capture_photo(self, camera: CameraInfo, output_path: str) -> bool:
- """Snapshot via GStreamer one-frame pipeline."""
- url = camera.extra.get("url", camera.device_path)
- if url.startswith("rtsp://"):
- src_args = ["rtspsrc", f"location={url}", "latency=300", "!", "decodebin"]
- else:
- src_args = ["souphttpsrc", f"location={url}", "!", "decodebin"]
+ pipeline = None
try:
- subprocess.run(
- [
- "gst-launch-1.0",
- "-e",
- *src_args,
- "!",
- "videoconvert",
- "!",
- "jpegenc",
- "!",
- "filesink",
- f"location={output_path}",
- ],
- capture_output=True,
- check=True,
- timeout=15,
- )
- return os.path.isfile(output_path)
- except Exception:
+ pipeline = Gst.parse_launch(
+ f"{self.get_gst_source(camera)} ! jpegenc snapshot=true ! "
+ f"filesink location={gst_quote(output_path)}")
+ self.prepare_pipeline(pipeline)
+ if pipeline.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
+ return False
+ message = pipeline.get_bus().timed_pop_filtered(
+ 15 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR)
+ return bool(message and message.type == Gst.MessageType.EOS
+ and os.path.isfile(output_path) and os.path.getsize(output_path) > 0)
+ except (OSError, ValueError, GLib.Error):
+ log.warning("Network snapshot failed", exc_info=True)
return False
+ finally:
+ if pipeline is not None:
+ pipeline.set_state(Gst.State.NULL)
diff --git a/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py b/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py
index 7477d3c..888d6af 100644
--- a/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py
+++ b/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py
@@ -3,6 +3,9 @@
from __future__ import annotations
import re
+import shutil
+from utils.urls import gst_quote
+from utils.video_formats import frame_rate
import subprocess
from typing import Any
@@ -18,17 +21,7 @@ def get_backend_type(self) -> BackendType:
return BackendType.LIBCAMERA
def is_available(self) -> bool:
- for cmd in ("cam", "libcamera-hello"):
- try:
- subprocess.run([cmd, "--version"], capture_output=True, timeout=5)
- return True
- except (
- FileNotFoundError,
- subprocess.CalledProcessError,
- subprocess.TimeoutExpired,
- ):
- continue
- return False
+ return shutil.which("cam") is not None
# -- detection -----------------------------------------------------------
@@ -56,7 +49,7 @@ def detect_cameras(self) -> list[CameraInfo]:
continue
cameras.append(
CameraInfo(
- id=f"libcamera:{idx}",
+ id=f"libcamera:{path}",
name=name,
backend=BackendType.LIBCAMERA,
device_path=path,
@@ -71,86 +64,23 @@ def detect_cameras(self) -> list[CameraInfo]:
# -- controls (limited at CLI level) -------------------------------------
def get_controls(self, camera: CameraInfo) -> list[CameraControl]:
- # libcamera CLI does not expose a rich control listing;
- # provide the most common ones as GStreamer element properties.
- return [
- CameraControl(
- id="brightness",
- name=_("Brightness"),
- category=ControlCategory.IMAGE,
- control_type=ControlType.INTEGER,
- value=0,
- default=0,
- minimum=-100,
- maximum=100,
- step=1,
- ),
- CameraControl(
- id="contrast",
- name=_("Contrast"),
- category=ControlCategory.IMAGE,
- control_type=ControlType.INTEGER,
- value=100,
- default=100,
- minimum=0,
- maximum=200,
- step=1,
- ),
- CameraControl(
- id="saturation",
- name=_("Saturation"),
- category=ControlCategory.IMAGE,
- control_type=ControlType.INTEGER,
- value=100,
- default=100,
- minimum=0,
- maximum=200,
- step=1,
- ),
- CameraControl(
- id="awb-mode",
- name=_("Auto White Balance"),
- category=ControlCategory.WHITE_BALANCE,
- control_type=ControlType.MENU,
- value="auto",
- default="auto",
- choices=[
- "auto",
- "incandescent",
- "tungsten",
- "fluorescent",
- "indoor",
- "daylight",
- "cloudy",
- "custom",
- ],
- ),
- CameraControl(
- id="exposure-mode",
- name=_("Exposure Mode"),
- category=ControlCategory.EXPOSURE,
- control_type=ControlType.MENU,
- value="normal",
- default="normal",
- choices=["normal", "short", "long", "custom"],
- ),
- ]
+ # cam does not provide a stable per-camera read/write control API here.
+ # Do not display fabricated hardware controls that never reach the device.
+ # The independent Effects page remains available for software adjustments.
+ return []
def set_control(self, camera: CameraInfo, control_id: str, value: Any) -> bool:
- # Stored in extra for pipeline rebuild
- camera.extra[f"ctrl_{control_id}"] = value
- return True
+ return False
# -- gstreamer -----------------------------------------------------------
def get_gst_source(self, camera: CameraInfo, fmt: VideoFormat | None = None) -> str:
cam_name = camera.device_path
- src = f"libcamerasrc camera-name={cam_name}"
+ src = f"libcamerasrc camera-name={gst_quote(cam_name)}"
if fmt:
caps = f"video/x-raw,width={fmt.width},height={fmt.height}"
if fmt.fps:
- best = int(max(fmt.fps))
- caps += f",framerate={best}/1"
+ caps += ",framerate=" + frame_rate(max(fmt.fps))
return f"{src} ! {caps}"
return src
@@ -161,14 +91,11 @@ def can_capture_photo(self) -> bool:
def capture_photo(self, camera: CameraInfo, output_path: str) -> bool:
try:
- subprocess.run(
- ["libcamera-still", "-o", output_path, "--nopreview", "-t", "1"],
- capture_output=True,
- check=True,
- timeout=15,
- )
+ subprocess.run(["gst-launch-1.0", "-e", "libcamerasrc",
+ f"camera-name={gst_quote(camera.device_path)}", "!", "videoconvert",
+ "!", "jpegenc", "snapshot=true", "!", "filesink",
+ f"location={gst_quote(output_path)}"], capture_output=True, check=True, timeout=15)
import os
-
- return os.path.isfile(output_path)
- except Exception:
+ return os.path.isfile(output_path) and os.path.getsize(output_path) > 0
+ except (OSError, ValueError, subprocess.SubprocessError):
return False
diff --git a/usr/share/biglinux/bigcam/core/backends/pipewire_backend.py b/usr/share/biglinux/bigcam/core/backends/pipewire_backend.py
index 796006b..b2346b0 100644
--- a/usr/share/biglinux/bigcam/core/backends/pipewire_backend.py
+++ b/usr/share/biglinux/bigcam/core/backends/pipewire_backend.py
@@ -3,6 +3,9 @@
from __future__ import annotations
import re
+import json
+import shutil
+from utils.urls import gst_quote
import subprocess
from typing import Any
@@ -17,73 +20,29 @@ def get_backend_type(self) -> BackendType:
return BackendType.PIPEWIRE
def is_available(self) -> bool:
- try:
- subprocess.run(["pw-cli", "info", "0"], capture_output=True, timeout=5)
- return True
- except (
- FileNotFoundError,
- subprocess.CalledProcessError,
- subprocess.TimeoutExpired,
- ):
- return False
+ return shutil.which("pw-dump") is not None
# -- detection -----------------------------------------------------------
def detect_cameras(self) -> list[CameraInfo]:
- cameras: list[CameraInfo] = []
try:
- result = subprocess.run(
- ["pw-cli", "list-objects"],
- capture_output=True,
- text=True,
- timeout=10,
- )
- if result.returncode != 0:
- return cameras
-
- # Parse pw-cli output for Video/Source nodes
- cameras = self._parse_pw_objects(result.stdout)
- except Exception:
- pass
- return cameras
+ result = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=10, check=True)
+ return self._parse_pw_objects(result.stdout)
+ except (OSError, ValueError, subprocess.SubprocessError):
+ return []
def _parse_pw_objects(self, output: str) -> list[CameraInfo]:
- cameras: list[CameraInfo] = []
- current_id = ""
- current_props: dict[str, str] = {}
-
- for line in output.splitlines():
- # New object: "id 42, type PipeWire:Interface:Node/3"
- obj_match = re.match(
- r"\s*id\s+(\d+),\s+type\s+PipeWire:Interface:Node", line
- )
- if obj_match:
- # Flush previous
- if current_id and self._is_video_source(current_props):
- cam = self._make_camera(current_id, current_props)
- if (
- "v4l2loopback" not in cam.name.lower()
- and "(v4l2)" not in cam.name.lower()
- ):
- cameras.append(cam)
- current_id = obj_match.group(1)
- current_props = {}
+ objects = json.loads(output)
+ if not isinstance(objects, list):
+ raise ValueError("Invalid PipeWire object list")
+ cameras = []
+ for item in objects:
+ if not isinstance(item, dict) or item.get("type") != "PipeWire:Interface:Node":
continue
-
- # Property: " media.class = \"Video/Source\""
- prop_match = re.match(r'\s+([\w.]+)\s*=\s*"?([^"]*)"?', line)
- if prop_match and current_id:
- current_props[prop_match.group(1)] = prop_match.group(2).strip()
-
- # Flush last
- if current_id and self._is_video_source(current_props):
- cam = self._make_camera(current_id, current_props)
- if (
- "v4l2loopback" not in cam.name.lower()
- and "(v4l2)" not in cam.name.lower()
- ):
- cameras.append(cam)
-
+ props = (item.get("info") or {}).get("props") or {}
+ node_id = item.get("id")
+ if isinstance(node_id, int) and node_id >= 0 and self._is_video_source(props):
+ cameras.append(self._make_camera(str(node_id), props))
return cameras
@staticmethod
diff --git a/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py b/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py
index b51fb50..9286af1 100644
--- a/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py
+++ b/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py
@@ -5,7 +5,10 @@
import json
import logging
import os
+from pathlib import Path
import re
+from utils.video_formats import frame_rate, source_caps
+from utils.urls import gst_quote
import subprocess
from typing import Any
import time
@@ -162,6 +165,14 @@ def _parse_devices(self, output: str) -> list[CameraInfo]:
)
# Check if photo capture is achievable (always yes for v4l2 via gstreamer snapshot)
cam.capabilities.append("photo")
+ # udev links survive /dev/videoN renumbering. Without a serial,
+ # profiles follow the physical port and model, not the enumeration.
+ links = [*sorted(Path("/dev/v4l/by-id").glob("*")),
+ *sorted(Path("/dev/v4l/by-path").glob("*"))]
+ stable = next((str(link) for link in links if os.path.realpath(link) == device), "")
+ if not stable:
+ stable = str(Path(f"/sys/class/video4linux/{Path(device).name}/device").resolve())
+ cam.extra["profile_id"] = f"v4l2:{stable}:{cam.name}"
cameras.append(cam)
return cameras
@@ -338,7 +349,7 @@ def _parse_ctrl_params(params_str: str) -> dict[str, Any]:
params: dict[str, Any] = {}
for token in re.findall(r"(\w+)=(-?\d+)", params_str):
params[token[0]] = int(token[1])
- flags_match = re.search(r"flags=(\w+)", params_str)
+ flags_match = re.search(r"flags=(.+)$", params_str)
if flags_match:
params["flags"] = flags_match.group(1)
return params
@@ -443,39 +454,23 @@ def _pw_gst_source(
if fmt.pixel_format == "MJPG":
caps = f"image/jpeg,width={fmt.width},height={fmt.height}"
if fmt.fps:
- best_fps = int(max(fmt.fps))
- caps += f",framerate={best_fps}/1"
+ best_fps = frame_rate(max(fmt.fps))
+ caps += f",framerate={best_fps}"
return f"{src} ! {caps} ! jpegdec max-errors=-1"
caps = f"video/x-raw,width={fmt.width},height={fmt.height}"
if fmt.fps:
- best_fps = int(max(fmt.fps))
- caps += f",framerate={best_fps}/1"
+ best_fps = frame_rate(max(fmt.fps))
+ caps += f",framerate={best_fps}"
return f"{src} ! {caps}"
return src
- def _v4l2_gst_source(
- self, device: str, camera: CameraInfo, fmt: VideoFormat | None
- ) -> str:
- """Build v4l2src element — exclusive device access (like guvcview)."""
- plf = self._detect_power_line_freq()
- src = (
- f"v4l2src device={device} io-mode=mmap do-timestamp=true"
- )
- if fmt is None:
- fmt = self._pick_best_format(camera)
+ def _v4l2_gst_source(self, device, camera, fmt):
+ source = f"v4l2src device={gst_quote(device)} io-mode=mmap do-timestamp=true"
+ fmt = fmt or self._pick_best_format(camera)
if fmt:
- if fmt.pixel_format == "MJPG":
- caps = f"image/jpeg,width={fmt.width},height={fmt.height}"
- if fmt.fps:
- best_fps = int(max(fmt.fps))
- caps += f",framerate={best_fps}/1"
- return f"{src} ! {caps} ! jpegdec max-errors=-1"
- caps = f"video/x-raw,width={fmt.width},height={fmt.height}"
- if fmt.fps:
- best_fps = int(max(fmt.fps))
- caps += f",framerate={best_fps}/1"
- return f"{src} ! {caps}"
- return src
+ caps, decoder = source_caps(fmt)
+ return f"{source} ! {caps}" + (f" ! {decoder}" if decoder else "")
+ return source
@staticmethod
def _find_pw_node_id(device_path: str) -> int | None:
diff --git a/usr/share/biglinux/bigcam/core/camera_backend.py b/usr/share/biglinux/bigcam/core/camera_backend.py
index c16d5b9..ec24767 100644
--- a/usr/share/biglinux/bigcam/core/camera_backend.py
+++ b/usr/share/biglinux/bigcam/core/camera_backend.py
@@ -89,5 +89,5 @@ def reset_all_controls(
) -> None:
"""Reset every control to its default value."""
for ctrl in controls:
- if ctrl.flags not in ("inactive", "read-only"):
+ if not any(flag in (ctrl.flags or "") for flag in ("inactive", "read-only")):
self.set_control(camera, ctrl.id, ctrl.default)
diff --git a/usr/share/biglinux/bigcam/core/camera_identity.py b/usr/share/biglinux/bigcam/core/camera_identity.py
new file mode 100644
index 0000000..8956d92
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/camera_identity.py
@@ -0,0 +1,28 @@
+"""Conservative deduplication: equal labels never prove equal cameras."""
+from __future__ import annotations
+import os
+from constants import BackendType
+
+_PRIORITY = {BackendType.V4L2: 0, BackendType.GPHOTO2: 1,
+ BackendType.LIBCAMERA: 2, BackendType.PIPEWIRE: 3}
+MANUAL_BACKENDS = {BackendType.IP, BackendType.PHONE, BackendType.SCRCPY, BackendType.AIRPLAY}
+
+
+def identity(camera):
+ physical = camera.extra.get("physical_id")
+ if physical:
+ return "physical", str(physical)
+ path = camera.extra.get("api.v4l2.path", camera.device_path)
+ if isinstance(path, str) and path.startswith("/dev/video"):
+ return "device", os.path.realpath(path)
+ return "id", camera.id
+
+
+def unique_cameras(cameras):
+ found = {}
+ for camera in cameras:
+ key = identity(camera)
+ old = found.get(key)
+ if old is None or _PRIORITY.get(camera.backend, 99) < _PRIORITY.get(old.backend, 99):
+ found[key] = camera
+ return sorted(found.values(), key=lambda c: (_PRIORITY.get(c.backend, 99), c.id))
diff --git a/usr/share/biglinux/bigcam/core/camera_manager.py b/usr/share/biglinux/bigcam/core/camera_manager.py
index d444c14..a0b319e 100644
--- a/usr/share/biglinux/bigcam/core/camera_manager.py
+++ b/usr/share/biglinux/bigcam/core/camera_manager.py
@@ -22,6 +22,9 @@
from core.backends.libcamera_backend import LibcameraBackend
from core.backends.pipewire_backend import PipeWireBackend
from core.backends.ip_backend import IPBackend
+from core.camera_identity import MANUAL_BACKENDS, unique_cameras
+from utils.async_worker import run_async
+from utils.i18n import _
class CameraManager(GObject.Object):
@@ -37,6 +40,10 @@ def __init__(self) -> None:
self._backends: list[CameraBackend] = []
self._cameras: list[CameraInfo] = []
self._detecting = False
+ self._detection_generation = 0
+ self._detection_task = None
+ self._pending_rescan = False
+ self._closed = False
self._first_detection = True
self._hotplug_timer: int | None = None
self._last_lsusb: str = ""
@@ -90,125 +97,76 @@ def get_backend(self, backend_type: BackendType) -> CameraBackend | None:
# -- detection -----------------------------------------------------------
def detect_cameras_async(self, force_emit: bool = False) -> None:
- """Run detection on all backends in a background thread."""
+ """Detect in parallel, then publish one complete generation on the main loop.
+
+ Partial snapshots used to remove cameras whose slower backend had not yet
+ returned. A hotplug event during a scan now requests one coalesced rescan.
+ """
+ if self._closed:
+ return
if self._detecting:
+ self._pending_rescan = True
return
self._detecting = True
self._force_emit = force_emit
+ self._detection_generation += 1
+ generation = self._detection_generation
+ backends = [b for b in self._backends if b.get_backend_type() != BackendType.IP]
- # Backend priority: lower number = higher priority for duplicate resolution
- _BACKEND_PRIORITY = {
- BackendType.V4L2: 0,
- BackendType.GPHOTO2: 1,
- BackendType.LIBCAMERA: 2,
- BackendType.PIPEWIRE: 3,
- }
-
- def _normalize_name(name: str) -> str:
- """Strip non-alphanumeric chars for duplicate detection."""
- return re.sub(r"[^a-z0-9 ]", "", name.lower()).strip()
-
- def _worker() -> None:
- all_cameras: list[CameraInfo] = []
- seen_ids: set[str] = set()
- seen_norm: list[tuple[str, int]] = [] # (norm_name, index_in_all_cameras)
- merge_lock = threading.Lock()
-
- backends_to_scan = [
- b for b in self._backends
- if b.get_backend_type() != BackendType.IP
- ]
-
- def _detect_one(b: CameraBackend) -> list[CameraInfo]:
- try:
- return b.detect_cameras()
- except Exception as exc:
- GLib.idle_add(self.emit, "camera-error", str(exc))
- return []
-
- completed = 0
- total = len(backends_to_scan)
-
+ def detect_one(backend):
try:
- with ThreadPoolExecutor(max_workers=total) as pool:
- futures = {
- pool.submit(_detect_one, b): b for b in backends_to_scan
- }
- for future in as_completed(futures):
- found = future.result()
- with merge_lock:
- for cam in found:
- if cam.id in seen_ids:
- continue
- norm = _normalize_name(cam.name)
- cam_prio = _BACKEND_PRIORITY.get(cam.backend, 99)
- dup_idx = -1
- for sn, idx in seen_norm:
- if sn in norm or norm in sn:
- dup_idx = idx
- break
- if dup_idx >= 0:
- # Duplicate found — replace if new camera has higher priority
- existing = all_cameras[dup_idx]
- existing_prio = _BACKEND_PRIORITY.get(existing.backend, 99)
- if cam_prio < existing_prio:
- seen_ids.discard(existing.id)
- seen_ids.add(cam.id)
- all_cameras[dup_idx] = cam
- # Update norm entry
- for i, (sn, sidx) in enumerate(seen_norm):
- if sidx == dup_idx:
- seen_norm[i] = (norm, dup_idx)
- break
- continue
- seen_ids.add(cam.id)
- seen_norm.append((norm, len(all_cameras)))
- all_cameras.append(cam)
- completed += 1
- snapshot = list(all_cameras)
- is_last = completed == total
-
- # Emit partial results so fast backends show up immediately
- if is_last:
- self._detecting = False
- GLib.idle_add(self._on_detection_done, snapshot)
- elif snapshot:
- # Only emit partial results when there are cameras to show
- GLib.idle_add(self._on_detection_done, snapshot)
+ return backend.detect_cameras()
except Exception:
+ log.exception("Camera detection failed for %s", type(backend).__name__)
+ # A failed scan is not proof that the device disappeared.
+ return [c for c in previous if c.backend == backend.get_backend_type()]
+
+ previous = self.cameras
+ def worker():
+ if not backends:
+ return []
+ with ThreadPoolExecutor(max_workers=min(4, len(backends))) as pool:
+ groups = list(pool.map(detect_one, backends))
+ return unique_cameras([camera for group in groups for camera in group])
+
+ def done(cameras):
+ if self._closed or generation != self._detection_generation:
+ return
+ self._detecting = False
+ self._on_detection_done(cameras)
+ if self._pending_rescan:
+ self._pending_rescan = False
+ self.detect_cameras_async(force_emit=True)
+
+ def failed(error):
+ if generation == self._detection_generation and not self._closed:
self._detecting = False
-
- threading.Thread(target=_worker, daemon=True).start()
+ self.emit("camera-error", _("Camera detection failed. Try refreshing the camera list."))
+ self._detection_task = run_async(worker, on_success=done, on_error=failed)
def _on_detection_done(self, cameras: list[CameraInfo]) -> bool:
- # Preserve manually-added cameras (IP, phone) across hotplug scans
- manual_backends = {BackendType.IP, BackendType.PHONE}
- manual_cameras = [
- c for c in self._cameras
- if c.backend in manual_backends or c.id.startswith("phone:")
- ]
- seen_ids = {c.id for c in cameras}
- for mc in manual_cameras:
- if mc.id not in seen_ids:
- cameras.append(mc)
-
- old_ids = {c.id for c in self._cameras}
- new_ids = {c.id for c in cameras}
- self._cameras = cameras
- changed = self._first_detection or old_ids != new_ids or getattr(self, "_force_emit", False)
+ manual = [c for c in self._cameras if c.backend in MANUAL_BACKENDS]
+ # Keep the live objects for existing sessions; refresh metadata in place.
+ old = {c.id: c for c in self._cameras}
+ merged = unique_cameras([*cameras, *manual])
+ result = []
+ for camera in merged:
+ existing = old.get(camera.id)
+ if existing is not None:
+ existing.name = camera.name
+ existing.formats = camera.formats or existing.formats
+ existing.capabilities = camera.capabilities
+ existing.extra.update(camera.extra)
+ result.append(existing)
+ else:
+ result.append(camera)
+ changed = self._first_detection or set(old) != {c.id for c in result} or self._force_emit
+ self._cameras = result
self._force_emit = False
- log.info(
- "Detection done: %d cameras, old=%s, new=%s, first=%s, emit=%s",
- len(cameras),
- old_ids,
- new_ids,
- self._first_detection,
- changed,
- )
if changed:
self._first_detection = False
self.emit("cameras-changed")
- return False
+ return GLib.SOURCE_REMOVE
def add_ip_cameras(self, entries: list[dict[str, str]]) -> None:
"""Add manually-configured IP cameras."""
@@ -269,7 +227,7 @@ def get_controls(self, camera: CameraInfo) -> list[CameraControl]:
return [
CameraControl(
id="audio_volume",
- name="Audio Volume",
+ name=_("Audio Volume"),
category=ControlCategory.ADVANCED,
control_type=ControlType.INTEGER,
value=vol,
@@ -310,21 +268,15 @@ def apply_anti_flicker(self, camera: CameraInfo) -> None:
# -- gstreamer proxy -----------------------------------------------------
- def get_gst_source(
- self, camera: CameraInfo, fmt: VideoFormat | None = None,
- prefer_v4l2: bool = False,
- ) -> str:
+ def get_gst_source(self, camera: CameraInfo, fmt: VideoFormat | None = None,
+ prefer_v4l2: bool = False) -> str:
backend_type = camera.backend
if backend_type in (BackendType.AIRPLAY, BackendType.SCRCPY):
backend_type = BackendType.V4L2
-
backend = self.get_backend(backend_type)
- if backend:
- try:
- return backend.get_gst_source(camera, fmt, prefer_v4l2=prefer_v4l2)
- except TypeError:
- return backend.get_gst_source(camera, fmt)
- return ""
+ if isinstance(backend, V4L2Backend):
+ return backend.get_gst_source(camera, fmt, prefer_v4l2=prefer_v4l2)
+ return backend.get_gst_source(camera, fmt) if backend else ""
# -- photo proxy ---------------------------------------------------------
@@ -503,3 +455,10 @@ def _poll_hotplug_loop(self, interval_s: float) -> None:
log.debug("Video device check failed", exc_info=True)
if changed:
GLib.idle_add(self.detect_cameras_async)
+
+ def close(self) -> None:
+ self._closed = True
+ self._detection_generation += 1
+ if self._detection_task:
+ self._detection_task.cancel()
+ self.stop_hotplug()
diff --git a/usr/share/biglinux/bigcam/core/camera_profiles.py b/usr/share/biglinux/bigcam/core/camera_profiles.py
index 3096eef..ddb9f76 100644
--- a/usr/share/biglinux/bigcam/core/camera_profiles.py
+++ b/usr/share/biglinux/bigcam/core/camera_profiles.py
@@ -1,60 +1,108 @@
-"""Camera profiles – save/load control presets per camera."""
-
+"""Private, atomic control profiles keyed by stable camera identity."""
from __future__ import annotations
-import json
-import os
+import hashlib
+import logging
+from pathlib import Path
import re
+import unicodedata
from core.camera_backend import CameraControl, CameraInfo
from utils import xdg
+from utils.atomic_json import locked, read_object, write_object
+
+log = logging.getLogger(__name__)
def _safe_filename(name: str) -> str:
- return re.sub(r"[^\w\-.]", "_", name)
+ if not isinstance(name, str):
+ raise ValueError("Profile name must be text")
+ name = unicodedata.normalize("NFC", name).strip()
+ if not name or name in {".", ".."} or len(name.encode("utf-8")) > 180:
+ raise ValueError("Invalid profile name")
+ safe = re.sub(r"[^\w\-.]", "_", name)
+ if safe.startswith("."):
+ safe = "profile_" + safe.lstrip(".")
+ return safe
+
+
+def _directory(camera: CameraInfo) -> Path:
+ root = Path(xdg.profiles_dir()).resolve()
+ identity = camera.extra.get("profile_id") or camera.id or f"{camera.backend.value}:{camera.device_path}"
+ directory = root / ("camera-" + hashlib.sha256(identity.encode("utf-8")).hexdigest())
+ if directory.is_symlink():
+ raise ValueError("Profile directory must not be a symbolic link")
+ directory.mkdir(mode=0o700, exist_ok=True)
+ if directory.resolve().parent != root:
+ raise ValueError("Profile directory is outside the profile root")
+ return directory
+
+
+def _legacy_directory(camera: CameraInfo) -> Path | None:
+ """Read old profiles only from a contained non-symlink name directory."""
+ if not isinstance(camera.name, str):
+ return None
+ old_name = re.sub(r"[^\w\-.]", "_", camera.name)
+ if old_name in {"", ".", ".."}:
+ return None
+ root = Path(xdg.profiles_dir()).resolve()
+ directory = root / old_name
+ if directory.is_symlink() or not directory.is_dir() or directory.resolve().parent != root:
+ return None
+ return directory
def _profile_path(camera: CameraInfo, profile_name: str) -> str:
- cam_dir = os.path.join(xdg.profiles_dir(), _safe_filename(camera.name))
- os.makedirs(cam_dir, exist_ok=True)
- return os.path.join(cam_dir, f"{_safe_filename(profile_name)}.json")
+ return str(_directory(camera) / f"{_safe_filename(profile_name)}.json")
def list_profiles(camera: CameraInfo) -> list[str]:
- """Return profile names available for *camera*."""
- cam_dir = os.path.join(xdg.profiles_dir(), _safe_filename(camera.name))
- if not os.path.isdir(cam_dir):
- return []
- names: list[str] = []
- for f in sorted(os.listdir(cam_dir)):
- if f.endswith(".json"):
- names.append(f[:-5])
- return names
-
-
-def save_profile(
- camera: CameraInfo, profile_name: str, controls: list[CameraControl]
-) -> str:
- """Persist current control values. Returns the file path."""
+ names: set[str] = set()
+ for directory in (_legacy_directory(camera), _directory(camera)):
+ if directory is not None:
+ for path in directory.glob("*.json"):
+ if path.is_file() and not path.is_symlink():
+ names.add(path.stem)
+ return sorted(names)
+
+
+def save_profile(camera: CameraInfo, profile_name: str, controls: list[CameraControl]) -> str:
path = _profile_path(camera, profile_name)
- data = {c.id: c.value for c in controls}
- with open(path, "w", encoding="utf-8") as f:
- json.dump(data, f, indent=2, default=str, ensure_ascii=False)
+ data = {control.id: control.value for control in controls
+ if not any(flag in (control.flags or "") for flag in ("read-only", "inactive"))}
+ with locked(path):
+ write_object(path, data)
return path
def load_profile(camera: CameraInfo, profile_name: str) -> dict[str, object]:
- """Return saved values as {control_id: value}."""
- path = _profile_path(camera, profile_name)
- if not os.path.isfile(path):
+ path = Path(_profile_path(camera, profile_name))
+ if not path.exists():
+ legacy = _legacy_directory(camera)
+ if legacy is not None:
+ path = legacy / f"{_safe_filename(profile_name)}.json"
+ try:
+ data = read_object(path)
+ if any(not isinstance(key, str) or not isinstance(value, (str, int, float, bool, type(None)))
+ for key, value in data.items()):
+ raise ValueError("Invalid profile values")
+ return data
+ except (OSError, ValueError, UnicodeError):
+ log.warning("Unable to read control profile", exc_info=True)
return {}
- with open(path, "r", encoding="utf-8") as f:
- return json.load(f)
def delete_profile(camera: CameraInfo, profile_name: str) -> bool:
- path = _profile_path(camera, profile_name)
- if os.path.isfile(path):
- os.remove(path)
- return True
- return False
+ name = f"{_safe_filename(profile_name)}.json"
+ removed = False
+ for directory in (_directory(camera), _legacy_directory(camera)):
+ if directory is None:
+ continue
+ path = directory / name
+ with locked(path):
+ if path.is_symlink():
+ raise ValueError("Refusing symbolic-link profile")
+ if path.is_file():
+ path.unlink()
+ removed = True
+ return removed
diff --git a/usr/share/biglinux/bigcam/core/effects.py b/usr/share/biglinux/bigcam/core/effects.py
index fe8bee2..7477fb5 100644
--- a/usr/share/biglinux/bigcam/core/effects.py
+++ b/usr/share/biglinux/bigcam/core/effects.py
@@ -125,7 +125,7 @@ def _apply_brightness(frame: np.ndarray, params: dict[str, float]) -> np.ndarray
return frame
alpha = 1.0 + contrast / 100.0
beta = brightness
- return cv2.convertScaleAbs(frame, alpha=alpha, beta=beta)
+ return cv2.addWeighted(frame, alpha, frame, 0.0, beta)
def _apply_sharpen(frame: np.ndarray, params: dict[str, float]) -> np.ndarray:
diff --git a/usr/share/biglinux/bigcam/core/frame_output.py b/usr/share/biglinux/bigcam/core/frame_output.py
new file mode 100644
index 0000000..1ab5cdb
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/frame_output.py
@@ -0,0 +1,73 @@
+"""Single-owner, bounded raw-frame output for a background browser camera."""
+import logging
+import queue
+import threading
+import cv2
+import gi
+gi.require_version("Gst", "1.0")
+from gi.repository import Gst
+from utils.urls import gst_quote
+from utils.gst_buffers import bgr_buffer
+
+log = logging.getLogger(__name__)
+
+
+class FrameOutput:
+ def __init__(self, device):
+ self.device = device
+ self._frames = queue.Queue(maxsize=2)
+ self._stop = threading.Event()
+ self._thread = threading.Thread(target=self._run, name="bigcam-phone-output", daemon=True)
+ self._thread.start()
+
+ def push(self, frame):
+ if self._stop.is_set():
+ return
+ try:
+ self._frames.put_nowait(frame.copy())
+ except queue.Full:
+ try:
+ self._frames.get_nowait()
+ except queue.Empty:
+ pass
+ try:
+ self._frames.put_nowait(frame.copy())
+ except queue.Full:
+ pass
+
+ def stop(self):
+ self._stop.set()
+ self._thread.join(timeout=2)
+
+ def _run(self):
+ pipeline = None
+ try:
+ while not self._stop.is_set():
+ try:
+ frame = self._frames.get(timeout=0.1)
+ except queue.Empty:
+ continue
+ if pipeline is None:
+ h, w = frame.shape[:2]
+ w = max(2, w // 2 * 2)
+ pipeline = Gst.parse_launch(
+ f"appsrc name=source format=time is-live=true do-timestamp=true block=false "
+ f"max-buffers=2 leaky-type=downstream caps=video/x-raw,format=BGR,width={w},height={h},framerate=30/1 ! "
+ f"queue max-size-buffers=2 leaky=downstream ! videoconvert ! video/x-raw,format=YUY2 ! "
+ f"v4l2sink device={gst_quote(self.device)} sync=false")
+ if pipeline.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
+ raise RuntimeError("Background virtual camera did not start")
+ source = pipeline.get_by_name("source")
+ error = pipeline.get_bus().pop_filtered(Gst.MessageType.ERROR)
+ if error:
+ raise RuntimeError(error.parse_error()[0].message)
+ if frame.shape[:2] != (h, w):
+ frame = cv2.resize(frame, (w, h))
+ if source.emit("push-buffer", bgr_buffer(frame)) != Gst.FlowReturn.OK:
+ break
+ except Exception:
+ log.exception("Background browser output failed")
+ finally:
+ self._stop.set()
+ if pipeline is not None:
+ pipeline.set_state(Gst.State.NULL)
diff --git a/usr/share/biglinux/bigcam/core/gphoto_session.py b/usr/share/biglinux/bigcam/core/gphoto_session.py
new file mode 100644
index 0000000..547bdfc
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/gphoto_session.py
@@ -0,0 +1,103 @@
+"""Own the two DSLR producer processes; never locate/kill processes by name."""
+from __future__ import annotations
+
+import re
+import signal
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+
+
+def stop_process(process: subprocess.Popen | None) -> None:
+ if process is None:
+ return
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=3)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=3)
+ else:
+ process.wait()
+
+
+class GPhotoSession:
+ """gphoto2 stdout -> FFmpeg stdin -> localhost MPEG-TS, with explicit ownership."""
+ def __init__(self, port: str, udp_port: int, bitrate: int = 5000):
+ if not re.fullmatch(r"usb:[0-9]{1,3},[0-9]{1,3}", port):
+ raise ValueError("A specific USB port is required")
+ if not 1024 <= int(udp_port) <= 65535:
+ raise ValueError("Invalid UDP port")
+ self.port, self.udp_port = port, int(udp_port)
+ self.bitrate = max(500, min(50000, int(bitrate)))
+ self.camera_process = None
+ self.encoder_process = None
+ self._diagnostics = None
+ self._lock = threading.RLock()
+
+ @property
+ def running(self) -> bool:
+ return all(p is not None and p.poll() is None for p in (self.camera_process, self.encoder_process))
+
+ def start(self) -> bool:
+ with self._lock:
+ if self.running:
+ return True
+ self.stop()
+ self._diagnostics = tempfile.TemporaryFile()
+ try:
+ self.camera_process = subprocess.Popen(
+ ["gphoto2", "--port", self.port, "--stdout", "--capture-movie"],
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=self._diagnostics,
+ start_new_session=True)
+ self.encoder_process = subprocess.Popen(
+ ["ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error", "-i", "pipe:0",
+ "-an", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p",
+ "-f", "mpegts", "-r", "30", "-codec:v", "mpeg1video",
+ "-b:v", f"{self.bitrate}k", "-bf", "0",
+ f"udp://127.0.0.1:{self.udp_port}?pkt_size=1316"],
+ stdin=self.camera_process.stdout, stdout=subprocess.DEVNULL,
+ stderr=self._diagnostics, start_new_session=True)
+ # Only FFmpeg owns the pipe reader after this point.
+ self.camera_process.stdout.close()
+ # Process startup is not a claim that a preview frame arrived.
+ return self.running
+ except (OSError, subprocess.SubprocessError):
+ self.stop()
+ raise
+
+ def stop(self) -> None:
+ with self._lock:
+ try:
+ stop_process(self.camera_process)
+ finally:
+ stop_process(self.encoder_process)
+ self.camera_process = self.encoder_process = None
+ if self._diagnostics:
+ self._diagnostics.close()
+ self._diagnostics = None
+
+
+def main() -> int:
+ if len(sys.argv) != 3:
+ raise SystemExit("Usage: gphoto_session.py usb:BUS,DEVICE UDP_PORT")
+ session = GPhotoSession(sys.argv[1], int(sys.argv[2]))
+ stopped = threading.Event()
+ for event in (signal.SIGINT, signal.SIGTERM):
+ signal.signal(event, lambda *_: stopped.set())
+ try:
+ if not session.start():
+ return 1
+ while not stopped.wait(0.2):
+ if not session.running:
+ return 1
+ return 0
+ finally:
+ session.stop()
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/usr/share/biglinux/bigcam/core/media_library.py b/usr/share/biglinux/bigcam/core/media_library.py
new file mode 100644
index 0000000..80d5372
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/media_library.py
@@ -0,0 +1,89 @@
+"""Media enumeration and content-identity thumbnail caching, independent of GTK."""
+from dataclasses import dataclass
+import hashlib
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+
+from PIL import Image, ImageOps
+from utils import xdg
+
+PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
+VIDEO_EXTS = {".mkv", ".mp4", ".webm", ".avi", ".mov"}
+
+
+@dataclass(frozen=True)
+class MediaEntry:
+ path: str
+ size: int
+ modified: float
+ modified_ns: int
+ is_video: bool
+
+ @property
+ def name(self):
+ return Path(self.path).name
+
+
+def scan(directory, extensions):
+ entries = []
+ try:
+ with os.scandir(directory) as items:
+ for item in items:
+ try:
+ if item.is_file(follow_symlinks=False) and Path(item.name).suffix.lower() in extensions:
+ st = item.stat(follow_symlinks=False)
+ entries.append(MediaEntry(item.path, st.st_size, st.st_mtime, st.st_mtime_ns,
+ Path(item.name).suffix.lower() in VIDEO_EXTS))
+ except OSError:
+ continue # A file can disappear between enumeration and stat.
+ except FileNotFoundError:
+ return []
+ return sorted(entries, key=lambda entry: (entry.modified_ns, entry.path), reverse=True)
+
+
+def thumbnail_key(entry):
+ identity = f"{Path(entry.path).absolute()}\0{entry.size}\0{entry.modified_ns}"
+ return hashlib.sha256(identity.encode()).hexdigest()
+
+
+def thumbnail(entry):
+ directory = Path(xdg.thumbs_dir())
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
+ target = directory / (thumbnail_key(entry) + ".png")
+ if target.is_file() and not target.is_symlink():
+ return str(target)
+ fd, temporary = tempfile.mkstemp(suffix=".png", dir=directory)
+ os.close(fd)
+ try:
+ if entry.is_video:
+ subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-protocol_whitelist", "file,pipe",
+ "-i", entry.path, "-frames:v", "1", "-vf", "scale=160:160:force_original_aspect_ratio=decrease",
+ "-threads", "1", temporary], check=True, capture_output=True, timeout=10)
+ else:
+ with Image.open(entry.path) as image:
+ image.draft("RGB", (320, 320))
+ image = ImageOps.exif_transpose(image)
+ image.thumbnail((160, 160))
+ image.convert("RGB").save(temporary, format="PNG")
+ if os.path.getsize(temporary) == 0:
+ raise ValueError("Empty thumbnail")
+ os.replace(temporary, target)
+ return str(target)
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+
+
+def duration(entry):
+ if not entry.is_video:
+ return ""
+ result = subprocess.run(["ffprobe", "-v", "error", "-protocol_whitelist", "file,pipe", "-show_entries", "format=duration",
+ "-of", "default=noprint_wrappers=1:nokey=1", entry.path],
+ check=True, capture_output=True, text=True, timeout=5)
+ value = float(result.stdout)
+ if not 0 <= value < 365 * 24 * 3600:
+ raise ValueError("Invalid duration")
+ minutes, seconds = divmod(int(value), 60)
+ return f"{minutes}:{seconds:02d}"
diff --git a/usr/share/biglinux/bigcam/core/phone_camera.py b/usr/share/biglinux/bigcam/core/phone_camera.py
index 7b81b7e..a82e2a3 100644
--- a/usr/share/biglinux/bigcam/core/phone_camera.py
+++ b/usr/share/biglinux/bigcam/core/phone_camera.py
@@ -1,1075 +1,423 @@
-"""Phone camera – HTTPS + WebSocket server that receives JPEG frames from a smartphone."""
-
+"""Authenticated, bounded HTTPS/WebSocket and optional WebTransport camera server."""
from __future__ import annotations
import asyncio
-import collections
+from concurrent.futures import ThreadPoolExecutor
+import html
+import io
+import json
import logging
import os
+from pathlib import Path
+import queue
import secrets
import socket
import ssl
-import subprocess
+import struct
import threading
import time
-from typing import Any, Callable, Optional
+from urllib.parse import quote
+import cv2
+import numpy as np
import gi
-
-gi.require_version("GLib", "2.0")
gi.require_version("Gst", "1.0")
-
from gi.repository import GLib, GObject, Gst
-
from utils.i18n import _
-
-log = logging.getLogger(__name__)
+from utils import xdg
+from core.phone_protocol import MAX_FRAME_BYTES, jpeg_size, valid_token
+from core.phone_tls import ensure_certificate
try:
from aiohttp import web
-
- _HAS_AIOHTTP = True
-except ImportError:
- _HAS_AIOHTTP = False
-
-try:
- from aioquic.asyncio import serve as quic_serve
- from aioquic.asyncio.protocol import QuicConnectionProtocol
- from aioquic.h3.connection import H3_ALPN, H3Connection
- from aioquic.h3.events import (
- DatagramReceived,
- H3Event,
- HeadersReceived,
- WebTransportStreamDataReceived,
- )
- from aioquic.quic.configuration import QuicConfiguration
- from aioquic.quic.events import ProtocolNegotiated, QuicEvent
-
- _HAS_QUIC = True
except ImportError:
- _HAS_QUIC = False
-
-_CERT_DIR = os.path.join(GLib.get_user_cache_dir(), "bigcam")
-_CERT_FILE = os.path.join(_CERT_DIR, "cert.pem")
-_KEY_FILE = os.path.join(_CERT_DIR, "key.pem")
+ web = None
+log = logging.getLogger(__name__)
DEFAULT_PORT = 8443
-
-
-# ---------------------------------------------------------------------------
-# HTML page served to the smartphone browser
-# ---------------------------------------------------------------------------
-
-_PHONE_HTML = """\
-
-
-
-
-
-BigCam
-
-
-
-
-
-
-Disconnected
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-Accept the security warning to allow camera access.
-
-
-
-
-"""
-
-
-def _cert_sha256_b64() -> str:
- """Return the base64-encoded SHA-256 hash of the DER-encoded certificate.
-
- Needed for WebTransport with self-signed certificates (serverCertificateHashes).
- """
- import base64
- import hashlib
-
- try:
- with open(_CERT_FILE, "rb") as f:
- pem = f.read()
- # Extract DER from PEM (between BEGIN/END CERTIFICATE markers)
- import re
-
- m = re.search(
- b"-----BEGIN CERTIFICATE-----\n(.+?)\n-----END CERTIFICATE-----",
- pem,
- re.DOTALL,
- )
- if not m:
- return ""
- der = base64.b64decode(m.group(1))
- return base64.b64encode(hashlib.sha256(der).digest()).decode("ascii")
- except Exception:
- return ""
-
-
-# ---------------------------------------------------------------------------
-# QUIC / WebTransport protocol handler (optional — requires aioquic)
-# ---------------------------------------------------------------------------
-
-if _HAS_QUIC:
-
- class _PhoneWTProtocol(QuicConnectionProtocol):
- """HTTP/3 WebTransport server protocol for phone camera streaming.
-
- Each video frame arrives as a complete unidirectional stream.
- Audio packets arrive as QUIC datagrams (unreliable, low latency).
- """
-
- def __init__(self, *args: Any, phone_server: Any = None, **kwargs: Any) -> None:
- super().__init__(*args, **kwargs)
- self._phone = phone_server
- self._h3: Optional[H3Connection] = None
- self._session_ids: set[int] = set()
- self._stream_bufs: dict[int, bytearray] = {}
-
- def quic_event_received(self, event: QuicEvent) -> None:
- if isinstance(event, ProtocolNegotiated):
- self._h3 = H3Connection(self._quic, enable_webtransport=True)
- if self._h3 is not None:
- for h3_event in self._h3.handle_event(event):
- self._h3_event_received(h3_event)
-
- def _h3_event_received(self, event: H3Event) -> None:
- if isinstance(event, HeadersReceived):
- headers = dict(event.headers)
- if (
- headers.get(b":method") == b"CONNECT"
- and headers.get(b":protocol") == b"webtransport"
- ):
- import urllib.parse
- path = headers.get(b":path", b"").decode("utf-8", errors="ignore")
- parsed = urllib.parse.urlparse(path)
- qs = urllib.parse.parse_qs(parsed.query)
- token = qs.get("token", [""])[0]
-
- if not token or not secrets.compare_digest(token, self._phone._token):
- self._h3.send_headers(
- stream_id=event.stream_id,
- headers=[(b":status", b"401")],
- )
- self.transmit()
- log.warning("WebTransport session rejected: Unauthorized")
- return
-
- self._session_ids.add(event.stream_id)
- self._h3.send_headers(
- stream_id=event.stream_id,
- headers=[(b":status", b"200")],
- )
- self.transmit()
- log.info("WebTransport session established")
-
- elif isinstance(event, WebTransportStreamDataReceived):
- sid = event.stream_id
- if sid not in self._stream_bufs:
- self._stream_bufs[sid] = bytearray()
- self._stream_bufs[sid].extend(event.data)
- if event.stream_ended:
- data = bytes(self._stream_bufs.pop(sid))
- if data:
- asyncio.ensure_future(
- self._phone._decode_and_emit_frame(data)
- )
-
- elif isinstance(event, DatagramReceived):
- # Audio packets: first byte 0x01, rest is PCM S16LE
- raw = event.data
- if raw and raw[0] == 0x01:
- self._phone._push_audio_data(bytes(raw[1:]))
+ASSETS = Path(__file__).resolve().parents[1] / "web"
+HEADERS = {"Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss:; media-src 'self' blob:; frame-ancestors 'none'"}
class PhoneCameraServer(GObject.Object):
- """HTTPS + WebSocket server that receives JPEG frames from a smartphone.
-
- The phone browser captures camera frames on a canvas, encodes them as
- JPEG, and sends the binary data over a WebSocket connection. The server
- decodes each frame with OpenCV and makes it available via a callback.
- """
-
__gsignals__ = {
"status-changed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
"connected": (GObject.SignalFlags.RUN_LAST, None, (int, int)),
"disconnected": (GObject.SignalFlags.RUN_LAST, None, ()),
}
- def __init__(self) -> None:
+ def __init__(self):
super().__init__()
- self._loop: Optional[asyncio.AbstractEventLoop] = None
- self._thread: Optional[threading.Thread] = None
- self._runner: Optional[Any] = None
+ self._lock = threading.RLock()
self._running = False
+ self._thread = self._loop = None
+ self._stop_request = threading.Event()
+ self._start_event = threading.Event()
+ self._start_error = ""
+ self._token = secrets.token_urlsafe(32)
self._port = DEFAULT_PORT
- self._width = 0
- self._height = 0
- self._ws_clients: set[Any] = set()
- self._token = secrets.token_urlsafe(16)
-
- # fn(numpy_bgr_frame) — called from the asyncio thread
- self._frame_callback: Optional[Callable] = None
- self._last_frame_time: float = 0.0
-
- # Audio playback — runs as separate process for isolation
- self._audio_proc: Optional[subprocess.Popen] = None
- self._audio_started = False
- self._desired_volume: float = 1.0
- self._desired_muted: bool = False
- self._audio_queue: collections.deque[bytes] = collections.deque(maxlen=5)
- self._audio_drain_thread: Optional[threading.Thread] = None
- self._audio_drain_stop = threading.Event()
-
- # -- public API ----------------------------------------------------------
+ self._frame_callback = None
+ self._audio_callback = None
+ self._width = self._height = 0
+ self._last_frame_time = 0
+ self._owner = None
+ self._session_generation = 0
+ self._pending_frame = None
+ self._decoder_task = None
+ self._audio_queue = queue.Queue(maxsize=8)
+ self._desired_volume = 1.0
+ self._desired_muted = False
+ self._audio_pipeline = None
+ self._audio_thread = None
+ self._audio_stop = threading.Event()
+ self._last_audio_sequence = -1
@staticmethod
- def available() -> bool:
- """Return True if aiohttp is installed."""
- return _HAS_AIOHTTP
+ def available():
+ return web is not None
@property
- def running(self) -> bool:
+ def running(self):
return self._running
@property
- def port(self) -> int:
+ def port(self):
return self._port
@property
- def resolution(self) -> tuple[int, int]:
+ def resolution(self):
return self._width, self._height
@property
- def is_connected(self) -> bool:
- """Return True if a phone is actively sending frames."""
- if self._ws_clients:
- return True
- # HTTP POST fallback: check if frames arrived recently
- return (time.monotonic() - self._last_frame_time) < 3.0
-
- def get_url(self) -> str:
- return f"https://{_get_local_ip()}:{self._port}/?token={self._token}"
-
- def set_frame_callback(self, callback: Optional[Callable]) -> None:
- self._frame_callback = callback
-
- def _start_audio_pipeline(self) -> None:
- """Start a separate gst-launch-1.0 subprocess for audio playback.
-
- Using a subprocess completely isolates audio playback from the
- main application's CPU/thread contention when multiple cameras
- are active. Data flows through a kernel pipe, providing natural
- jitter absorption independent of Python's GIL.
- """
- if self._audio_started:
- return
- try:
- self._audio_proc = subprocess.Popen(
- [
- "gst-launch-1.0", "-q",
- "fdsrc", "fd=0", "blocksize=4096",
- "!", "audio/x-raw,format=S16LE,rate=16000,channels=1,layout=interleaved",
- "!", "queue", "max-size-time=50000000", "leaky=downstream",
- "!", "audioconvert",
- "!", "audioresample",
- "!", "autoaudiosink", "sync=false",
- ],
- stdin=subprocess.PIPE,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL,
- )
- except FileNotFoundError:
- log.error("gst-launch-1.0 not found, audio disabled")
- return
- self._audio_started = True
- log.info("Phone audio subprocess started (pid=%d)", self._audio_proc.pid)
- # Start dedicated drain thread
- self._audio_drain_stop.clear()
- self._audio_drain_thread = threading.Thread(
- target=self._audio_drain_loop, name="phone-audio-drain", daemon=True
- )
- self._audio_drain_thread.start()
-
- def _stop_audio_pipeline(self) -> None:
- """Stop the audio playback subprocess."""
- self._audio_drain_stop.set()
- if self._audio_drain_thread:
- self._audio_drain_thread.join(timeout=2.0)
- self._audio_drain_thread = None
- proc = self._audio_proc
- if proc:
- try:
- if proc.stdin:
- proc.stdin.close()
- proc.terminate()
- proc.wait(timeout=3)
- except Exception:
- proc.kill()
- self._audio_proc = None
- self._audio_started = False
- self._audio_queue.clear()
+ def is_connected(self):
+ return self._running and self._owner is not None and time.monotonic() - self._last_frame_time < 5
@property
- def audio_pid(self) -> Optional[int]:
- """PID of the audio subprocess (for pactl volume control)."""
- proc = self._audio_proc
- return proc.pid if proc and proc.poll() is None else None
+ def audio_pid(self):
+ return None # No gst-launch subprocess: playback has an owned pipeline.
- def set_audio_volume(self, value: float) -> None:
- """Set audio volume (0.0 – 1.0)."""
- self._desired_volume = max(0.0, min(value, 1.0))
+ def get_url(self):
+ return f"https://{_get_local_ip()}:{self._port}/?token={quote(self._token)}"
- def set_audio_muted(self, muted: bool) -> None:
- """Mute or unmute audio."""
- self._desired_muted = muted
+ def set_frame_callback(self, callback):
+ with self._lock:
+ self._frame_callback = callback
- def _push_audio_data(self, pcm_data: bytes) -> None:
- """Enqueue PCM data for the drain thread.
+ def set_audio_callback(self, callback):
+ with self._lock:
+ self._audio_callback = callback
- Called from the asyncio thread. Never touches GStreamer directly.
- """
- self._audio_queue.append(pcm_data)
- if not self._audio_started:
- log.info("First audio packet (%d bytes), starting audio subprocess", len(pcm_data))
- GLib.idle_add(self._start_audio_pipeline)
+ def set_audio_volume(self, value):
+ self._desired_volume = max(0.0, min(float(value), 1.0))
- def _audio_drain_loop(self) -> None:
- """Dedicated thread that drains the audio queue into the subprocess stdin.
+ def set_audio_muted(self, muted):
+ self._desired_muted = bool(muted)
- The kernel pipe buffer (~64KB = ~2s at 16kHz S16LE) provides natural
- jitter absorption. Volume/mute are applied in software to avoid
- the complexity of pactl PID lookup during playback.
- """
- try:
- import numpy as np
- _has_np = True
- except ImportError:
- _has_np = False
-
- while not self._audio_drain_stop.is_set():
- try:
- chunk = self._audio_queue.popleft()
- except IndexError:
- self._audio_drain_stop.wait(0.008)
- continue
-
- proc = self._audio_proc
- if proc is None or proc.stdin is None or proc.poll() is not None:
- continue
+ def _notify(self, name, *args):
+ generation = self._session_generation
+ def notify():
+ if generation == self._session_generation:
+ self.emit(name, *args)
+ return GLib.SOURCE_REMOVE
+ GLib.idle_add(notify)
- # Apply volume/mute in software
- if self._desired_muted:
- chunk = b"\x00" * len(chunk)
- elif abs(self._desired_volume - 1.0) > 0.01 and _has_np:
- samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32)
- samples *= self._desired_volume
- np.clip(samples, -32768, 32767, out=samples)
- chunk = samples.astype(np.int16).tobytes()
-
- try:
- proc.stdin.write(chunk)
- proc.stdin.flush()
- except (BrokenPipeError, OSError):
- log.warning("Audio subprocess pipe broken, stopping")
- break
-
- def start(self, port: int = DEFAULT_PORT) -> tuple[bool, str]:
- """Start the HTTPS server. Returns (success, message)."""
- if not _HAS_AIOHTTP:
- log.error("python-aiohttp not installed")
+ def start(self, port=DEFAULT_PORT):
+ if not self.available():
return False, _("python-aiohttp is not installed")
- if self._running:
- return True, ""
-
- self._port = port
- self._start_error: str = ""
- self._start_event = threading.Event()
-
- self._loop = asyncio.new_event_loop()
- self._thread = threading.Thread(
- target=self._run_loop, name="phone-cam", daemon=True
- )
- self._thread.start()
-
- # Wait up to 5s for the server to confirm it's listening
- if not self._start_event.wait(timeout=5):
- log.error("Phone camera server failed to start within timeout")
- self._loop = None
- self._thread = None
+ if not isinstance(port, int) or not 1024 <= port <= 65535:
+ return False, _("Invalid server port")
+ with self._lock:
+ if self._running:
+ return True, ""
+ if self._thread and self._thread.is_alive():
+ return False, _("The previous server session is still stopping.")
+ self._port = port
+ self._token = secrets.token_urlsafe(32)
+ self._stop_request = threading.Event()
+ self._start_event = threading.Event()
+ self._start_error = ""
+ self._thread = threading.Thread(target=self._run_loop, name="bigcam-phone-server", daemon=True)
+ self._thread.start()
+ if not self._start_event.wait(12):
+ self._stop_request.set()
return False, _("Server did not start in time")
-
if self._start_error:
- msg = self._start_error
- self._loop = None
- self._thread = None
- return False, msg
-
- self._running = True
- GLib.idle_add(self.emit, "status-changed", "listening")
- return True, ""
-
- def stop(self) -> None:
- if not self._running:
- return
- self._running = False
-
- had_clients = bool(self._ws_clients) or self._width > 0
-
- if self._loop and self._loop.is_running():
-
- async def _shutdown() -> None:
- for ws in list(self._ws_clients):
- await ws.close()
- self._ws_clients.clear()
- if getattr(self, "_quic_server", None) is not None:
- self._quic_server.close()
- self._quic_server = None
- if self._runner:
- await self._runner.cleanup()
-
- fut = asyncio.run_coroutine_threadsafe(_shutdown(), self._loop)
- try:
- fut.result(timeout=5)
- except Exception:
- log.debug("Phone camera shutdown timed out", exc_info=True)
- self._loop.call_soon_threadsafe(self._loop.stop)
-
- if self._thread:
- self._thread.join(timeout=5)
- self._thread = None
- self._loop = None
- self._width = self._height = 0
- self._stop_audio_pipeline()
- # Emit "disconnected" so the window cleans up the phone camera entry
- # even if the WebSocket handler's finally block didn't get a chance.
- if had_clients:
- GLib.idle_add(self.emit, "disconnected")
- GLib.idle_add(self.emit, "status-changed", "stopped")
-
- # -- asyncio server ------------------------------------------------------
-
- def _run_loop(self) -> None:
+ return False, self._start_error
+ return self._running, ""
+
+ def stop(self):
+ self._stop_request.set()
+ thread = self._thread
+ if thread and thread is not threading.current_thread():
+ thread.join(timeout=8)
+ # Keep the reference if it is still alive: a new server must not race it.
+ if thread and thread.is_alive():
+ log.warning("Phone server shutdown is still in progress")
+
+ def _run_loop(self):
+ loop = asyncio.new_event_loop()
+ self._loop = loop
+ asyncio.set_event_loop(loop)
+ self._decoder = ThreadPoolExecutor(max_workers=1, thread_name_prefix="bigcam-jpeg")
try:
- _ensure_cert()
- asyncio.set_event_loop(self._loop)
- self._loop.run_until_complete(self._start_server())
- self._start_event.set()
- self._loop.run_forever()
- except OSError as exc:
- log.error("Phone camera server failed: %s", exc)
- if "address already in use" in str(exc).lower() or getattr(exc, 'errno', 0) == 98:
- self._start_error = _("Port %d is already in use") % self._port
- else:
- self._start_error = str(exc)
- self._start_event.set()
+ loop.run_until_complete(self._serve())
except Exception as exc:
- log.error("Phone camera server failed: %s", exc, exc_info=True)
- self._start_error = str(exc)
+ self._start_error = _("Could not start the camera server: %s") % str(exc)
+ log.exception("Phone server stopped with an error")
+ finally:
+ self._running = False
self._start_event.set()
-
- async def _start_server(self) -> None:
- app = web.Application(client_max_size=10 * 1024 * 1024)
- app.router.add_get("/", self._handle_index)
- app.router.add_get("/ws", self._handle_ws)
- app.router.add_post("/frame", self._handle_frame_post)
-
- ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
- ssl_ctx.load_cert_chain(_CERT_FILE, _KEY_FILE)
-
- self._runner = web.AppRunner(app)
- await self._runner.setup()
- site = web.TCPSite(self._runner, "0.0.0.0", self._port, ssl_context=ssl_ctx)
- await site.start()
- log.info("Phone camera server listening on port %d (HTTPS/TCP)", self._port)
-
- # Start QUIC/WebTransport alongside HTTPS (same port, UDP vs TCP)
- self._quic_server = None
- if _HAS_QUIC:
+ tasks = asyncio.all_tasks(loop)
+ for task in tasks:
+ task.cancel()
+ if tasks:
+ loop.run_until_complete(asyncio.gather(*tasks, return_exceptions=True))
+ loop.run_until_complete(loop.shutdown_asyncgens())
+ self._decoder.shutdown(wait=True, cancel_futures=True)
+ self._audio_stop.set()
+ if self._audio_thread:
+ self._audio_thread.join(timeout=2)
+ loop.close()
+ self._notify("status-changed", "stopped")
+
+ async def _serve(self):
+ cert, key, self._cert_hash = ensure_certificate(Path(xdg.cache_dir()) / "phone-tls")
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.minimum_version = ssl.TLSVersion.TLSv1_2
+ context.load_cert_chain(cert, key)
+ app = web.Application(client_max_size=MAX_FRAME_BYTES)
+ app.router.add_get("/", self._index)
+ app.router.add_get("/status", self._status)
+ app.router.add_get("/phone.js", self._asset)
+ app.router.add_get("/audio-worklet.js", self._asset)
+ app.router.add_get("/ws", self._websocket)
+ app.router.add_post("/frame", self._post)
+ app.router.add_post("/disconnect", self._disconnect_http)
+ runner = web.AppRunner(app, access_log=None, shutdown_timeout=2)
+ quic = None
+ try:
+ await runner.setup()
+ await web.TCPSite(runner, "0.0.0.0", self._port, ssl_context=context).start()
try:
- quic_config = QuicConfiguration(
- alpn_protocols=H3_ALPN,
- is_client=False,
- max_datagram_frame_size=65536,
- )
- quic_config.load_cert_chain(_CERT_FILE, _KEY_FILE)
- phone_ref = self
-
- self._quic_server = await quic_serve(
- "0.0.0.0",
- self._port,
- configuration=quic_config,
- create_protocol=lambda *a, **kw: _PhoneWTProtocol(
- *a, phone_server=phone_ref, **kw
- ),
- )
- log.info("QUIC/WebTransport server listening on port %d (UDP)", self._port)
- except Exception as exc:
- log.warning("QUIC server failed to start: %s", exc)
-
- # Pre-compute cert hash for WebTransport self-signed cert support
- self._cert_hash_b64 = _cert_sha256_b64()
-
- def _verify_token(self, request: web.Request) -> bool:
- """Verify the authentication token in the request query parameters."""
- token = request.query.get("token")
- if not token or not secrets.compare_digest(token, self._token):
+ from aioquic.asyncio import serve
+ from aioquic.h3.connection import H3_ALPN
+ from aioquic.quic.configuration import QuicConfiguration
+ from core.phone_transport import PhoneTransport
+ configuration = QuicConfiguration(is_client=False, alpn_protocols=H3_ALPN,
+ max_data=2 * MAX_FRAME_BYTES, max_stream_data=MAX_FRAME_BYTES,
+ max_datagram_frame_size=1200)
+ configuration.load_cert_chain(cert, key)
+ quic = await serve("0.0.0.0", self._port, configuration=configuration,
+ create_protocol=lambda *a, **kw: PhoneTransport(*a, phone_server=self, **kw))
+ except ImportError:
+ log.info("Optional QUIC support is not installed; WebSocket remains available")
+ except Exception:
+ log.warning("QUIC is unavailable; WebSocket remains available", exc_info=True)
+ self._has_quic = quic is not None
+ self._running = not self._stop_request.is_set()
+ self._last_frame_time = time.monotonic()
+ self._notify("status-changed", "listening")
+ self._start_event.set()
+ while not self._stop_request.is_set():
+ await asyncio.sleep(0.1)
+ if self._owner is not None and time.monotonic() - self._last_frame_time > 10:
+ owner = self._owner
+ self.release(owner)
+ if not isinstance(owner, str):
+ result = owner.close()
+ if asyncio.iscoroutine(result):
+ await result
+ finally:
+ owner = self._owner
+ self.release(owner)
+ if owner is not None and not isinstance(owner, str):
+ result = owner.close()
+ if asyncio.iscoroutine(result):
+ await result
+ if quic:
+ quic.close()
+ await runner.cleanup()
+
+ def _authorized(self, request):
+ if not valid_token(request.query.get("token"), self._token):
+ raise web.HTTPUnauthorized(text="Unauthorized", headers=HEADERS)
+
+ async def _status(self, request):
+ self._authorized(request)
+ return web.json_response({"ready": self._running, "busy": self._owner is not None}, headers=HEADERS)
+
+ async def _index(self, request):
+ self._authorized(request)
+ from core.phone_strings import phone_strings
+ config = {"quic": self._has_quic, "certHash": self._cert_hash, "strings": phone_strings()}
+ encoded = json.dumps(config, ensure_ascii=True).replace("<", "\u003c")
+ page = (ASSETS / "phone.html").read_text(encoding="utf-8")
+ page = page.replace("__CONFIG__", encoded).replace("__TOKEN__", quote(self._token))
+ return web.Response(text=page, content_type="text/html", headers=HEADERS)
+
+ async def _asset(self, request):
+ self._authorized(request)
+ # Fixed registered routes, never a client-supplied filesystem path.
+ name = "audio-worklet.js" if request.path == "/audio-worklet.js" else "phone.js"
+ return web.Response(text=(ASSETS / name).read_text(), content_type="application/javascript", headers=HEADERS)
+
+ def claim(self, owner):
+ if self._owner is not None and self._owner != owner:
return False
+ if self._owner is None:
+ self._session_generation += 1
+ self._last_audio_sequence = -1
+ self._owner = owner
+ self._last_frame_time = time.monotonic()
return True
- async def _handle_index(self, request: web.Request) -> web.Response:
- if not self._verify_token(request):
- return web.Response(status=401, text="Unauthorized")
-
- # Inject cert hash and QUIC availability into the HTML page
- html = _PHONE_HTML.replace(
- "/*CERT_HASH*/",
- f"const CERT_HASH='{self._cert_hash_b64}';" if self._cert_hash_b64 else "const CERT_HASH='';",
- ).replace(
- "/*HAS_QUIC*/",
- "const HAS_QUIC=true;" if self._quic_server else "const HAS_QUIC=false;",
- )
- return web.Response(text=html, content_type="text/html")
-
- async def _handle_frame_post(self, request: web.Request) -> web.Response:
- """HTTP POST fallback for browsers that reject WSS with self-signed certs (Safari/iOS)."""
- if not self._verify_token(request):
- return web.Response(status=401, text="Unauthorized")
-
- try:
- import cv2
- import numpy as np
- except ImportError:
- return web.Response(status=500, text="opencv not available")
-
- data = await request.read()
- if not data:
- return web.Response(status=400)
-
- jpg_array = np.frombuffer(data, dtype=np.uint8)
- bgr = cv2.imdecode(jpg_array, cv2.IMREAD_COLOR)
- if bgr is None:
- return web.Response(status=400)
-
- h, w = bgr.shape[:2]
- if w != self._width or h != self._height:
- self._width, self._height = w, h
- GLib.idle_add(self.emit, "connected", w, h)
- GLib.idle_add(self.emit, "status-changed", "connected")
-
- cb = self._frame_callback
- if cb:
- cb(bgr)
-
- self._last_frame_time = time.monotonic()
-
- return web.Response(status=204)
-
- async def _decode_and_emit_frame(self, data: bytes) -> None:
- """Decode JPEG data and emit to the frame callback (shared by WS and QUIC)."""
- try:
- import cv2
- import numpy as np
- except ImportError:
+ def release(self, owner):
+ if owner is None or self._owner != owner:
return
-
- loop = asyncio.get_event_loop()
- arr = np.frombuffer(data, dtype=np.uint8)
- bgr = await loop.run_in_executor(None, cv2.imdecode, arr, cv2.IMREAD_COLOR)
- if bgr is None:
- return
-
- h, w = bgr.shape[:2]
- if w != self._width or h != self._height:
- self._width, self._height = w, h
- GLib.idle_add(self.emit, "connected", w, h)
- GLib.idle_add(self.emit, "status-changed", "connected")
-
- cb = self._frame_callback
- if cb:
- cb(bgr)
- self._last_frame_time = time.monotonic()
-
- async def _handle_ws(self, request: web.Request) -> web.WebSocketResponse:
- if not self._verify_token(request):
- raise web.HTTPUnauthorized(text="Unauthorized")
-
- ws = web.WebSocketResponse(max_msg_size=10 * 1024 * 1024)
- await ws.prepare(request)
- self._ws_clients.add(ws)
-
- log.info("Phone camera WebSocket connected from %s", request.remote)
-
+ self._owner = None
+ self._session_generation += 1
+ self._pending_frame = None
+ self._width = self._height = 0
+ self._notify("disconnected")
+ self._notify("status-changed", "listening" if self._running else "stopped")
+
+ async def _websocket(self, request):
+ self._authorized(request)
+ if self._owner is not None:
+ raise web.HTTPConflict(text="A camera is already connected", headers=HEADERS)
+ ws = web.WebSocketResponse(max_msg_size=MAX_FRAME_BYTES, heartbeat=15, compress=False)
+ if not self.claim(ws):
+ raise web.HTTPConflict()
try:
- import cv2
- import numpy as np
- except ImportError:
- log.error("OpenCV (cv2) required for phone camera")
- await ws.close()
- return ws
+ await ws.prepare(request)
+ async for message in ws:
+ if message.type == web.WSMsgType.BINARY:
+ self.receive(bytes(message.data), ws)
+ elif message.type == web.WSMsgType.ERROR:
+ break
+ finally:
+ self.release(ws)
+ return ws
- first_frame = True
- loop = asyncio.get_event_loop()
+ def _http_owner(self, request):
+ client = request.query.get("client", "")
+ if not client.isascii() or not 16 <= len(client) <= 80 or not all(c.isalnum() or c == "-" for c in client):
+ raise web.HTTPBadRequest(text="Invalid session")
+ return "http:" + client
+
+ async def _post(self, request):
+ self._authorized(request)
+ owner = self._http_owner(request)
+ if not self.claim(owner):
+ raise web.HTTPConflict(text="A camera is already connected", headers=HEADERS)
+ packet = await request.read()
+ if not self.receive(packet, owner):
+ raise web.HTTPBadRequest(text="Invalid media packet", headers=HEADERS)
+ return web.Response(status=204, headers=HEADERS)
+
+ async def _disconnect_http(self, request):
+ self._authorized(request)
+ self.release(self._http_owner(request))
+ return web.Response(status=204, headers=HEADERS)
+
+ def receive(self, packet, owner):
+ if self._owner != owner or not packet or len(packet) > MAX_FRAME_BYTES:
+ return False
+ if packet[0] == 1:
+ # Each PCM packet is 20 ms @ 16 kHz mono, with a big-endian sequence.
+ # Reliable streams can complete out of order; stale packets are dropped
+ # rather than replayed backwards. No UDP-size assumptions are made.
+ if len(packet) != 645:
+ return False
+ sequence = struct.unpack_from(">I", packet, 1)[0]
+ if sequence <= self._last_audio_sequence:
+ return False
+ self._last_audio_sequence = sequence
+ with self._lock:
+ callback = self._audio_callback
+ if callback:
+ callback(packet[5:])
+ try:
+ self._audio_queue.put_nowait((self._session_generation, packet[5:]))
+ except queue.Full:
+ return False
+ if self._audio_thread is None or not self._audio_thread.is_alive():
+ self._audio_stop = threading.Event()
+ self._audio_thread = threading.Thread(target=self._audio, name="bigcam-phone-audio", daemon=True)
+ self._audio_thread.start()
+ return True
+ if not packet.startswith(b"\xff\xd8"):
+ return False
+ self._pending_frame = (self._session_generation, packet)
+ if self._decoder_task is None or self._decoder_task.done():
+ self._decoder_task = asyncio.create_task(self._decode_pending())
+ return True
- def _decode_jpeg(data: bytes):
- arr = np.frombuffer(data, dtype=np.uint8)
- return cv2.imdecode(arr, cv2.IMREAD_COLOR)
+ async def _decode_pending(self):
+ while self._pending_frame is not None:
+ generation, packet = self._pending_frame
+ self._pending_frame = None
+ def decode():
+ jpeg_size(packet) # Refuse large dimensions before allocating pixels.
+ image = cv2.imdecode(np.frombuffer(packet, dtype=np.uint8), cv2.IMREAD_COLOR)
+ if image is None:
+ raise ValueError("Invalid JPEG")
+ if generation == self._session_generation:
+ with self._lock:
+ callback = self._frame_callback
+ if callback:
+ callback(image)
+ return image.shape[:2]
+ try:
+ h, w = await asyncio.get_running_loop().run_in_executor(self._decoder, decode)
+ except Exception:
+ log.debug("Rejected phone frame", exc_info=True)
+ continue
+ if generation != self._session_generation or self._owner is None:
+ continue
+ first = (w, h) != (self._width, self._height)
+ self._width, self._height = w, h
+ self._last_frame_time = time.monotonic()
+ if first:
+ self._notify("connected", w, h)
+ self._notify("status-changed", "connected")
+ def _audio(self):
+ pipeline = None
try:
- async for msg in ws:
- if msg.type == web.WSMsgType.BINARY:
- data = msg.data
- if not data:
- continue
-
- # Audio packet: first byte is 0x01, rest is PCM S16LE
- if data[0] == 0x01:
- self._push_audio_data(bytes(data[1:]))
- continue
-
- # Video frame: decode JPEG in thread pool
- bgr = await loop.run_in_executor(None, _decode_jpeg, bytes(data))
- if bgr is None:
- continue
-
- h, w = bgr.shape[:2]
-
- if first_frame or (w != self._width or h != self._height):
- self._width, self._height = w, h
- GLib.idle_add(self.emit, "connected", w, h)
- GLib.idle_add(self.emit, "status-changed", "connected")
- first_frame = False
-
- cb = self._frame_callback
- if cb:
- cb(bgr)
- self._last_frame_time = time.monotonic()
-
- elif msg.type in (
- web.WSMsgType.ERROR,
- web.WSMsgType.CLOSE,
- ):
+ pipeline = Gst.parse_launch(
+ "appsrc name=pcm format=time is-live=true do-timestamp=true block=false max-buffers=8 leaky-type=downstream "
+ "caps=audio/x-raw,format=S16LE,rate=16000,channels=1,layout=interleaved ! "
+ "audioconvert ! audioresample ! volume name=volume ! autoaudiosink sync=false")
+ pipeline.set_state(Gst.State.PLAYING)
+ source = pipeline.get_by_name("pcm")
+ volume = pipeline.get_by_name("volume")
+ while not self._audio_stop.is_set():
+ try:
+ generation, pcm = self._audio_queue.get(timeout=0.1)
+ except queue.Empty:
+ continue
+ if generation != self._session_generation:
+ continue
+ volume.set_property("volume", self._desired_volume)
+ volume.set_property("mute", self._desired_muted)
+ if pipeline.get_bus().pop_filtered(Gst.MessageType.ERROR):
break
+ if source.emit("push-buffer", Gst.Buffer.new_wrapped(pcm)) != Gst.FlowReturn.OK:
+ break
+ except Exception:
+ log.exception("Phone audio playback failed")
finally:
- self._ws_clients.discard(ws)
- self._width = self._height = 0
- self._stop_audio_pipeline()
- GLib.idle_add(self.emit, "disconnected")
- GLib.idle_add(self.emit, "status-changed", "disconnected")
- log.info("Phone camera WebSocket disconnected")
-
- return ws
+ if pipeline is not None:
+ pipeline.set_state(Gst.State.NULL)
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-
-def _get_local_ip() -> str:
- """Best-effort local LAN IP address."""
+def _get_local_ip():
+ # UDP connect only determines routing; it sends no packet.
try:
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- s.settimeout(0.5)
- s.connect(("8.8.8.8", 80))
- ip = s.getsockname()[0]
- s.close()
- return ip
- except Exception:
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
+ sock.connect(("192.0.2.1", 9))
+ return sock.getsockname()[0]
+ except OSError:
return "127.0.0.1"
-
-
-def _ensure_cert() -> None:
- """Generate a self-signed TLS certificate if missing."""
- if os.path.isfile(_CERT_FILE) and os.path.isfile(_KEY_FILE):
- return
- os.makedirs(_CERT_DIR, exist_ok=True)
- # Write to temp files first to avoid partial cert on crash/race
- import tempfile
- tmp_key = tmp_cert = ""
- try:
- fd_key, tmp_key = tempfile.mkstemp(dir=_CERT_DIR, suffix=".key.tmp")
- os.close(fd_key)
- fd_cert, tmp_cert = tempfile.mkstemp(dir=_CERT_DIR, suffix=".cert.tmp")
- os.close(fd_cert)
- subprocess.run(
- [
- "openssl",
- "req",
- "-x509",
- "-newkey",
- "rsa:2048",
- "-keyout",
- tmp_key,
- "-out",
- tmp_cert,
- "-days",
- "365",
- "-nodes",
- "-subj",
- "/CN=BigCam Phone Camera",
- ],
- check=True,
- capture_output=True,
- timeout=15,
- )
- os.chmod(tmp_key, 0o600)
- os.rename(tmp_key, _KEY_FILE)
- os.rename(tmp_cert, _CERT_FILE)
- log.info("Generated self-signed certificate at %s", _CERT_FILE)
- except Exception:
- # Clean up temp files on failure
- for f in (tmp_key, tmp_cert):
- if f and os.path.exists(f):
- os.unlink(f)
- raise
diff --git a/usr/share/biglinux/bigcam/core/phone_protocol.py b/usr/share/biglinux/bigcam/core/phone_protocol.py
new file mode 100644
index 0000000..7e6b7fb
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/phone_protocol.py
@@ -0,0 +1,58 @@
+"""Pure input limits and authenticated WebTransport stream assembly."""
+from collections import OrderedDict
+import io
+import secrets
+import time
+from PIL import Image
+
+MAX_FRAME_BYTES = 8 * 1024 * 1024
+MAX_PIXELS = 16 * 1024 * 1024
+
+
+def valid_token(value, expected):
+ return isinstance(value, str) and value.isascii() and 0 < len(value) <= 128 and secrets.compare_digest(value, expected)
+
+
+def jpeg_size(data):
+ if not data or len(data) > MAX_FRAME_BYTES or not data.startswith(b"\xff\xd8"):
+ raise ValueError("Invalid camera JPEG")
+ with Image.open(io.BytesIO(data)) as image:
+ w, h = image.size
+ if image.format != "JPEG" or w <= 0 or h <= 0 or max(w, h) > 8192 or w * h > MAX_PIXELS:
+ raise ValueError("Camera image exceeds the decoding limit")
+ return w, h
+
+
+class StreamAssembler:
+ """One authorized CONNECT session, finite streams/bytes and no stale fragments."""
+ def __init__(self, session_id, clock=time.monotonic):
+ self.session_id = session_id
+ self.clock = clock
+ self.streams = OrderedDict()
+ self.total = 0
+
+ def discard(self, stream_id):
+ entry = self.streams.pop(stream_id, None)
+ if entry:
+ self.total -= len(entry[1])
+
+ def feed(self, session_id, stream_id, data, ended):
+ if session_id != self.session_id or self.session_id is None:
+ raise PermissionError("Stream does not belong to the authenticated session")
+ now = self.clock()
+ if any(now - created > 3 for created, _buf in self.streams.values()):
+ raise ValueError("Unfinished camera stream expired")
+ if stream_id not in self.streams:
+ if len(self.streams) >= 8:
+ raise ValueError("Too many concurrent camera streams")
+ self.streams[stream_id] = (now, bytearray())
+ _created, buf = self.streams[stream_id]
+ if len(buf) + len(data) > MAX_FRAME_BYTES or self.total + len(data) > 2 * MAX_FRAME_BYTES:
+ raise ValueError("Camera stream byte budget exceeded")
+ buf.extend(data)
+ self.total += len(data)
+ if ended:
+ result = bytes(buf)
+ self.discard(stream_id)
+ return result
+ return None
diff --git a/usr/share/biglinux/bigcam/core/phone_strings.py b/usr/share/biglinux/bigcam/core/phone_strings.py
new file mode 100644
index 0000000..69516cb
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/phone_strings.py
@@ -0,0 +1,18 @@
+"""gettext catalog shared with the browser client; no translated executable code."""
+from utils.i18n import _
+
+
+def phone_strings():
+ return {
+ "title": _("Phone as Webcam"), "disconnected": _("Disconnected"),
+ "connecting": _("Connecting…"), "connected": _("Connected"),
+ "start": _("Start"), "stop": _("Stop"), "switch": _("Switch camera"),
+ "resolution": _("Resolution"), "camera": _("Camera"), "quality": _("Quality"),
+ "fps": _("Frames per second"), "auto": _("Auto"), "back": _("Rear camera"),
+ "front": _("Front camera"), "low": _("Low"), "medium": _("Medium"), "high": _("High"),
+ "microphone": _("Include microphone audio"), "preview": _("Camera preview"),
+ "tip": _("Only connect on a trusted network. Anyone with this address can connect to the camera service."),
+ "error": _("Connection failed. Check permissions, the address and whether another device is connected."),
+ "audioError": _("Microphone audio is unavailable. Stop and reconnect, or turn off microphone audio."),
+ "authentication": _("This address is no longer valid. Scan the current QR code in BigCam."),
+ }
diff --git a/usr/share/biglinux/bigcam/core/phone_tls.py b/usr/share/biglinux/bigcam/core/phone_tls.py
new file mode 100644
index 0000000..e33b5a1
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/phone_tls.py
@@ -0,0 +1,55 @@
+"""Private, short-lived ECDSA certificates for WebTransport certificate hashes."""
+import base64
+import hashlib
+import os
+from pathlib import Path
+import ssl
+import subprocess
+import tempfile
+from utils.atomic_json import locked
+
+
+def ensure_certificate(directory):
+ directory = Path(directory)
+ directory.mkdir(parents=True, exist_ok=True, mode=0o700)
+ if directory.is_symlink():
+ raise ValueError("Certificate directory cannot be a symlink")
+ os.chmod(directory, 0o700)
+ cert, key = directory / "cert.pem", directory / "key.pem"
+ with locked(directory / "certificate"):
+ valid = False
+ if cert.is_symlink() or key.is_symlink():
+ raise ValueError("Certificate files cannot be symlinks")
+ if cert.is_file() and key.is_file():
+ try:
+ text = subprocess.check_output(["openssl", "x509", "-in", str(cert), "-noout", "-text"],
+ timeout=5, env={"PATH": "/usr/bin:/bin", "LC_ALL": "C"}).decode()
+ # Only reuse certificates generated by this helper (10-day validity).
+ marker = directory / "ecdsa-v1"
+ valid = (marker.is_file() and "prime256v1" in text and
+ subprocess.run(["openssl", "x509", "-in", str(cert), "-checkend", "86400", "-noout"],
+ capture_output=True, timeout=5).returncode == 0)
+ if valid:
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
+ context.load_cert_chain(cert, key)
+ except (OSError, subprocess.SubprocessError, ssl.SSLError):
+ valid = False
+ if not valid:
+ with tempfile.TemporaryDirectory(dir=directory) as temporary:
+ c, k = Path(temporary) / "cert.pem", Path(temporary) / "key.pem"
+ subprocess.run(["openssl", "req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
+ "-sha256", "-nodes", "-days", "10", "-subj", "/CN=BigCam",
+ "-addext", "subjectAltName=DNS:bigcam.local,IP:127.0.0.1",
+ "-keyout", str(k), "-out", str(c)],
+ capture_output=True, check=True, timeout=10,
+ env={"PATH": "/usr/bin:/bin", "LC_ALL": "C"})
+ os.chmod(k, 0o600)
+ os.chmod(c, 0o600)
+ os.replace(k, key)
+ os.replace(c, cert)
+ (directory / "ecdsa-v1").touch(mode=0o600)
+ os.chmod(key, 0o600)
+ os.chmod(cert, 0o600)
+ der = ssl.PEM_cert_to_DER_cert(cert.read_text())
+ digest = base64.b64encode(hashlib.sha256(der).digest()).decode("ascii")
+ return str(cert), str(key), digest
diff --git a/usr/share/biglinux/bigcam/core/phone_transport.py b/usr/share/biglinux/bigcam/core/phone_transport.py
new file mode 100644
index 0000000..45e169d
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/phone_transport.py
@@ -0,0 +1,68 @@
+"""Optional aioquic adapter; all media streams require their CONNECT session."""
+from urllib.parse import parse_qs, urlsplit
+from aioquic.asyncio.protocol import QuicConnectionProtocol
+from aioquic.h3.connection import H3Connection
+from aioquic.h3.events import HeadersReceived, DataReceived, WebTransportStreamDataReceived, DatagramReceived
+from aioquic.quic.events import ProtocolNegotiated, ConnectionTerminated, StreamReset
+from core.phone_protocol import StreamAssembler, valid_token
+
+
+class PhoneTransport(QuicConnectionProtocol):
+ def __init__(self, *args, phone_server, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.phone = phone_server
+ self.h3 = None
+ self.assembler = StreamAssembler(None)
+
+ def quic_event_received(self, event):
+ if isinstance(event, ConnectionTerminated):
+ self.phone.release(self)
+ self.assembler.streams.clear()
+ return
+ if isinstance(event, StreamReset):
+ self.assembler.discard(event.stream_id)
+ if event.stream_id == self.assembler.session_id:
+ self.phone.release(self)
+ self.assembler.session_id = None
+ if isinstance(event, ProtocolNegotiated):
+ self.h3 = H3Connection(self._quic, enable_webtransport=True)
+ if self.h3 is not None:
+ for item in self.h3.handle_event(event):
+ self._event(item)
+
+ def _event(self, event):
+ if isinstance(event, HeadersReceived):
+ headers = dict(event.headers)
+ try:
+ parsed = urlsplit(headers.get(b":path", b"").decode("utf-8"))
+ token = parse_qs(parsed.query, max_num_fields=8).get("token", [""])[0]
+ except (ValueError, UnicodeError):
+ token, parsed = "", None
+ authorized = (headers.get(b":method") == b"CONNECT" and headers.get(b":protocol") == b"webtransport"
+ and parsed and parsed.path == "/camera" and valid_token(token, self.phone._token))
+ if not authorized:
+ status = b"401"
+ elif self.assembler.session_id is not None or not self.phone.claim(self):
+ status = b"409"
+ else:
+ self.assembler = StreamAssembler(event.stream_id)
+ status = b"200"
+ self.h3.send_headers(event.stream_id, [(b":status", status)], end_stream=status != b"200")
+ self.transmit()
+ elif isinstance(event, DataReceived):
+ if event.stream_id == self.assembler.session_id and event.stream_ended:
+ self.phone.release(self)
+ self.assembler.session_id = None
+ elif isinstance(event, WebTransportStreamDataReceived):
+ try:
+ packet = self.assembler.feed(event.session_id, event.stream_id, event.data, event.stream_ended)
+ if packet:
+ self.phone.receive(packet, self)
+ except (ValueError, PermissionError):
+ self.phone.release(self)
+ self.close(error_code=0x100, reason_phrase="Invalid camera stream")
+ elif isinstance(event, DatagramReceived):
+ # PCM uses reliable, sequenced streams, not oversized QUIC datagrams.
+ # No unauthenticated datagram can allocate audio resources.
+ if event.stream_id != self.assembler.session_id:
+ self.close(error_code=0x100, reason_phrase="Unauthorized datagram")
diff --git a/usr/share/biglinux/bigcam/core/recording_config.py b/usr/share/biglinux/bigcam/core/recording_config.py
new file mode 100644
index 0000000..6797b25
--- /dev/null
+++ b/usr/share/biglinux/bigcam/core/recording_config.py
@@ -0,0 +1,57 @@
+"""Validated, immutable recording options and encoder fallbacks."""
+from dataclasses import dataclass
+import math
+
+
+@dataclass(frozen=True)
+class RecordingConfig:
+ video_codec: str = "h264"
+ audio_codec: str = "opus"
+ container: str = "mkv"
+ video_bitrate: int = 8000
+
+ def __post_init__(self):
+ if self.video_codec not in {"h264", "h265", "vp9", "mjpeg"}:
+ raise ValueError("Unknown video codec")
+ if self.audio_codec not in {"opus", "aac", "mp3", "vorbis"}:
+ raise ValueError("Unknown audio codec")
+ if self.container not in {"mkv", "mp4", "webm"}:
+ raise ValueError("Unknown container")
+ if isinstance(self.video_bitrate, bool) or not math.isfinite(float(self.video_bitrate)):
+ raise ValueError("Invalid video bitrate")
+ object.__setattr__(self, "video_bitrate", max(500, min(50000, int(self.video_bitrate))))
+ if self.container == "webm":
+ object.__setattr__(self, "video_codec", "vp9")
+ if self.audio_codec not in {"opus", "vorbis"}:
+ object.__setattr__(self, "audio_codec", "opus")
+ if self.container == "mp4":
+ # mp4mux supports VP9 and Opus, but not JPEG or Vorbis.
+ if self.video_codec == "mjpeg":
+ object.__setattr__(self, "video_codec", "h264")
+ if self.audio_codec == "vorbis":
+ object.__setattr__(self, "audio_codec", "aac")
+
+ @property
+ def extension(self):
+ return "." + self.container
+
+ @property
+ def muxer(self):
+ return {"mkv": "matroskamux", "mp4": "mp4mux", "webm": "webmmux"}[self.container]
+
+ def encoders(self):
+ """Ordered candidates. Each must actually encode before it is selected."""
+ br = self.video_bitrate
+ if self.video_codec == "h265":
+ return [("nvh265enc", f"nvh265enc bitrate={br} rc-mode=cbr ! h265parse"),
+ ("vah265enc", f"vah265enc bitrate={br} rate-control=cbr ! h265parse"),
+ ("x265enc", f"x265enc bitrate={br} speed-preset=veryfast tune=zerolatency ! h265parse")]
+ if self.video_codec == "vp9":
+ return [("vavp9enc", f"vavp9enc bitrate={br} rate-control=cbr"),
+ ("vp9enc", f"vp9enc target-bitrate={br * 1000} end-usage=cbr deadline=1 cpu-used=4 threads=4")]
+ if self.video_codec == "mjpeg":
+ # JPEG quality is explicit; a target bitrate is not applicable.
+ return [("jpegenc", "jpegenc quality=90")]
+ return [("nvh264enc", f"nvh264enc bitrate={br} rc-mode=cbr ! h264parse"),
+ ("vah264enc", f"vah264enc bitrate={br} rate-control=cbr ! h264parse"),
+ ("x264enc", f"x264enc bitrate={br} speed-preset=veryfast tune=zerolatency key-int-max=120 ! h264parse")]
diff --git a/usr/share/biglinux/bigcam/core/stream_engine.py b/usr/share/biglinux/bigcam/core/stream_engine.py
index e2da330..ca0fd04 100644
--- a/usr/share/biglinux/bigcam/core/stream_engine.py
+++ b/usr/share/biglinux/bigcam/core/stream_engine.py
@@ -14,7 +14,7 @@
gi.require_version("GstVideo", "1.0")
gi.require_version("Gdk", "4.0")
-from gi.repository import Gst, Gdk, GLib, GObject
+from gi.repository import Gst, GstVideo, Gdk, GLib, GObject
import numpy as np
@@ -29,10 +29,17 @@
from constants import BackendType
from core.camera_backend import CameraInfo, VideoFormat
+from core.backends.ip_backend import IPBackend
from core.camera_manager import CameraManager
from core.effects import EffectPipeline
from core.virtual_camera import VirtualCamera
from utils.i18n import _
+from utils.async_worker import run_async
+from utils.frame_buffers import bgr_from_bgra, LatestValue
+from utils.settings_manager import SettingsManager
+from utils.video_formats import frame_rate
+from utils.urls import gst_quote
+import time
Gst.init(None)
log = logging.getLogger(__name__)
@@ -50,26 +57,12 @@
def _stderr_suppress() -> None:
- """Redirect fd 2 to /dev/null (refcounted, thread-safe)."""
- global _stderr_refcount, _stderr_orig_fd
- with _stderr_lock:
- if _stderr_refcount == 0:
- _stderr_orig_fd = os.dup(2)
- devnull = os.open(os.devnull, os.O_WRONLY)
- os.dup2(devnull, 2)
- os.close(devnull)
- _stderr_refcount += 1
+ # Kept for the legacy capture fallback. Never redirect process-wide fd 2.
+ return None
def _stderr_restore() -> None:
- """Restore fd 2 when the last suppressor exits."""
- global _stderr_refcount, _stderr_orig_fd
- with _stderr_lock:
- _stderr_refcount -= 1
- if _stderr_refcount == 0 and _stderr_orig_fd is not None:
- os.dup2(_stderr_orig_fd, 2)
- os.close(_stderr_orig_fd)
- _stderr_orig_fd = None
+ return None
def _find_device_users(device_path: str) -> list[str]:
@@ -135,112 +128,41 @@ def start(self) -> bool:
return True
def _loop(self) -> None:
- """Capture-once, loop-forever strategy for background virtual cameras.
-
- The key insight: USB cameras use isochronous transfers that consume
- bandwidth at their NATIVE framerate regardless of what the software
- requests via CAP_PROP_FPS. A camera that only supports 25fps will
- send 25 frames/second over USB even if we only read 5.
-
- To truly free USB bandwidth, we:
- 1. Open the camera briefly and grab ONE frame
- 2. CLOSE the camera immediately (releases USB bandwidth to zero)
- 3. Push that static frame in a loop at 1 FPS to keep the
- v4l2loopback device alive for consuming applications (OBS, etc.)
- """
- import cv2
- import time
-
- LOOP_FPS = 1 # 1 frame/sec is enough to keep v4l2loopback alive
- LOOP_INTERVAL = 1.0 / LOOP_FPS
-
- captured_frame = None
-
- # Retry loop to handle V4L2 device transition delay
- for attempt in range(15):
- if self._stop.is_set():
- return
-
- cap = cv2.VideoCapture(self._device_path, cv2.CAP_V4L2)
- if cap.isOpened():
- cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
- cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
- ret, test_frame = cap.read()
- cap.release() # IMMEDIATELY release — frees USB bandwidth
- if ret:
- captured_frame = test_frame
- break
-
- if cap.isOpened():
- cap.release()
-
- log.debug("BgVcamFeeder: waiting for %s (attempt %d)...",
- self._device_path, attempt + 1)
- time.sleep(0.2)
-
- if captured_frame is None:
- log.warning("BgVcamFeeder: failed to capture frame from %s", self._device_path)
- return
-
- h, w = captured_frame.shape[:2]
- self._w = w
- self._h = h
-
- # Convert the single frame to BGRA once
- bgra_frame = cv2.cvtColor(captured_frame, cv2.COLOR_BGR2BGRA)
- bgra_bytes = bgra_frame.tobytes()
-
- # Build appsrc -> v4l2sink pipeline
- nthreads = min(os.cpu_count() or 2, 4)
- max_bytes = w * h * 4 * 2
- pipeline_str = (
- f"appsrc name=src emit-signals=false is-live=true format=time block=false max-bytes={max_bytes} "
- f"caps=video/x-raw,format=BGRA,width={w},height={h},framerate={LOOP_FPS}/1 "
- f"! queue max-size-buffers=2 leaky=downstream silent=true "
- f"! videoconvert n-threads={nthreads} "
- "! video/x-raw,format=YUY2 "
- f"! v4l2sink device={self._loopback} sync=false"
- )
+ """Live, bounded GStreamer forwarding; no concurrent OpenCV release/read."""
+ pipeline = None
try:
- self._pipeline = Gst.parse_launch(pipeline_str)
- except GLib.Error as e:
- log.error("BgVcamFeeder: pipeline parse error: %s", e)
- return
-
- self._appsrc = self._pipeline.get_by_name("src")
- ret_state = self._pipeline.set_state(Gst.State.PLAYING)
- if ret_state == Gst.StateChangeReturn.FAILURE:
- log.warning("BgVcamFeeder: pipeline failed to start for %s", self._name)
- self._pipeline.set_state(Gst.State.NULL)
+ source = f"v4l2src device={gst_quote(self._device_path)} ! decodebin"
+ pipeline = Gst.parse_launch(
+ f"{source} ! queue max-size-buffers=2 leaky=downstream ! videoconvert ! "
+ "video/x-raw,format=YUY2 ! "
+ f"v4l2sink device={gst_quote(self._loopback)} sync=false")
+ self._pipeline = pipeline
+ if pipeline.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
+ raise RuntimeError("Background camera could not start")
+ bus = pipeline.get_bus()
+ while not self._stop.wait(0.1):
+ message = bus.timed_pop_filtered(0, Gst.MessageType.ERROR | Gst.MessageType.EOS)
+ if message is not None:
+ if message.type == Gst.MessageType.ERROR:
+ log.warning("Background camera stopped after a stream error")
+ break
+ except Exception:
+ log.exception("Background camera forwarding failed")
+ finally:
+ if pipeline is not None:
+ pipeline.set_state(Gst.State.NULL)
self._pipeline = None
- return
-
- log.info(
- "BgVcamFeeder (static frame): %s -> %s (%dx%d, USB released)",
- self._device_path, self._loopback, w, h,
- )
-
- # Loop the static frame to keep v4l2loopback alive
- appsrc = self._appsrc
- while not self._stop.is_set() and appsrc:
- buf = Gst.Buffer.new_wrapped(bgra_bytes)
- ret = appsrc.emit("push-buffer", buf)
- if ret != Gst.FlowReturn.OK:
- log.warning("BgVcamFeeder: push-buffer returned %s - stopping", ret)
- break
- time.sleep(LOOP_INTERVAL)
+ self._appsrc = None
def stop(self) -> None:
- """Stop the feeder and release resources."""
self._stop.set()
- if self._thread is not None:
- self._thread.join(timeout=3.0)
- self._thread = None
- if self._pipeline is not None:
- self._pipeline.set_state(Gst.State.NULL)
- self._pipeline = None
- self._appsrc = None
- log.info("BgVcamFeeder stopped: %s", self._name)
+ thread = self._thread
+ if thread is not None and thread is not threading.current_thread():
+ thread.join(timeout=3)
+ if thread.is_alive():
+ log.warning("Background camera teardown is still pending")
+ else:
+ self._thread = None
class StreamEngine(GObject.Object):
@@ -256,6 +178,15 @@ class StreamEngine(GObject.Object):
def __init__(self, camera_manager: CameraManager) -> None:
super().__init__()
self._manager = camera_manager
+ self._settings = SettingsManager()
+ self._generation = 0
+ self._texture_slot = LatestValue()
+ self._has_received_frame = False
+ self._snapshot_request = threading.Event()
+ self._snapshot_ready = threading.Event()
+ self._vcam_resolving = False
+ self._bg_pending = set()
+ self._bg_generation = 0
self._pipeline: Gst.Pipeline | None = None
self._bus_watch_id: int | None = None
self._current_camera: CameraInfo | None = None
@@ -425,6 +356,7 @@ def fps(self) -> float:
def _start_fps_counter(self) -> None:
self._frame_count = 0
+ self._fps_last_time = time.monotonic()
self._current_fps = 0.0
if self._fps_timer_id is not None:
GLib.source_remove(self._fps_timer_id)
@@ -437,9 +369,11 @@ def _stop_fps_counter(self) -> None:
self._current_fps = 0.0
def _update_fps_counter(self) -> bool:
- self._current_fps = self._frame_count
+ now = time.monotonic()
+ self._current_fps = self._frame_count / max(now - self._fps_last_time, 0.001)
+ self._fps_last_time = now
self._frame_count = 0
- return True
+ return GLib.SOURCE_CONTINUE
def _on_frame_probe(
self, pad: Gst.Pad, info: Gst.PadProbeInfo
@@ -452,6 +386,13 @@ def _on_frame_probe(
def _apply_frame_processing(self, bgr: np.ndarray) -> np.ndarray:
"""Apply software effects to a BGR frame (effects, QR overlay).
Note: Zoom and Sharpness are now handled natively via GPU in GStreamer."""
+ if self._pipeline is None and (self._zoom_level > 1 or self._pan or self._tilt):
+ h, w = bgr.shape[:2]
+ zoom = max(self._zoom_level, 1.5 if self._pan or self._tilt else 1.0)
+ cw, ch = max(1, int(w / zoom)), max(1, int(h / zoom))
+ x = int((w - cw) * (self._pan + 1) / 2)
+ y = int((h - ch) * (self._tilt + 1) / 2)
+ bgr = cv2.resize(bgr[y:y + ch, x:x + cw], (w, h))
if self._effects.has_active_effects():
bgr = self._effects.apply(bgr)
@@ -507,6 +448,8 @@ def _distribute_processed_frame(
virtual camera without an extra BGR->BGRA conversion.
"""
self._last_probe_bgr = bgr
+ if self._snapshot_request.is_set():
+ self._snapshot_ready.set()
if self._vcam_device and self._last_probe_bgr is not None:
if bgra_direct is not None:
self._schedule_vcam_push(bgra_direct, w, h)
@@ -525,137 +468,35 @@ def _has_processing_work(self) -> bool:
or self._zoom_level > 1.0 or self._sharpness > 0.0
or self._pan != 0.0 or self._tilt != 0.0)
- def _on_paintable_probe(
- self, pad: Gst.Pad, info: Gst.PadProbeInfo
- ) -> Gst.PadProbeReturn:
- """Buffer probe on tee sink - applies OpenCV effects via buffer replacement."""
+ def _on_paintable_probe(self, pad: Gst.Pad, info: Gst.PadProbeInfo) -> Gst.PadProbeReturn:
+ generation = self._generation
self._frame_count += 1
-
- # Deferred vcam: resolve after first frame is on screen
- if self._vcam_resolve_pending:
- self._vcam_resolve_pending = False
- self._resolve_vcam_async()
-
- has_work = self._has_processing_work()
- is_recording = self._video_recorder and self._video_recorder.is_recording
- # Fast path: no effects/overlays - only grab BGR every 10th frame for photos
- # If virtual camera is active but no effects, process every 2nd frame
- # to reduce memory pressure (~108 MB/s -> ~54 MB/s of temp allocations).
- if not has_work and not is_recording:
- if not self._vcam_device and self._frame_count % 10 != 0:
- return Gst.PadProbeReturn.OK
- if self._vcam_device and self._frame_count % 2 != 0 and self._frame_count % 10 != 0:
- return Gst.PadProbeReturn.OK
-
- buf = info.get_buffer()
- if buf is None:
+ self._notify_first_frame(generation)
+ work = self._has_processing_work()
+ recording = self._video_recorder and self._video_recorder.is_recording
+ if not work and not recording and not self._vcam_device and not self._snapshot_request.is_set() and self._frame_count % 10:
return Gst.PadProbeReturn.OK
- caps = pad.get_current_caps()
- if caps is None:
- return Gst.PadProbeReturn.OK
- s = caps.get_structure(0)
- w = s.get_value("width")
- h = s.get_value("height")
- # Cache format string - it never changes during a pipeline's lifetime
- if not self._probe_cached_fmt:
- self._probe_cached_fmt = s.get_string("format") or ""
- fmt = self._probe_cached_fmt
- self._probe_debug_count += 1
- if self._probe_debug_count <= 3:
- log.debug(f"paintable_probe: fmt={fmt}, {w}x{h}")
-
- ok, map_info = buf.map(Gst.MapFlags.READ)
- if not ok:
+ buf, caps = info.get_buffer(), pad.get_current_caps()
+ if buf is None or caps is None:
return Gst.PadProbeReturn.OK
- bgr = None
- result = None
try:
- # Use numpy view directly on mapped buffer - no bytes() copy
- raw_arr = np.frombuffer(map_info.data, dtype=np.uint8)
- if fmt in ("BGRA", "BGRx"):
- frame = raw_arr.reshape((h, w, 4))
- bgr = frame[:, :, :3] # View, no copy yet
- elif fmt == "BGR":
- bgr = raw_arr.reshape((h, w, 3)) # View
- elif fmt == "RGB":
- rgb = raw_arr.reshape((h, w, 3))
- bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
- elif fmt == "I420":
- yuv = raw_arr.reshape((h * 3 // 2, w))
- bgr = cv2.cvtColor(yuv, cv2.COLOR_YUV2BGR_I420)
- elif fmt == "NV12":
- yuv = raw_arr.reshape((h * 3 // 2, w))
- bgr = cv2.cvtColor(yuv, cv2.COLOR_YUV2BGR_NV12)
- elif fmt in ("YUY2", "YUYV"):
- yuv = raw_arr.reshape((h, w * 2))
- bgr = cv2.cvtColor(yuv, cv2.COLOR_YUV2BGR_YUY2)
- if bgr is not None:
- if has_work:
- # Single copy for processing; views become owned arrays
- processed = self._apply_frame_processing(bgr.copy())
- self._distribute_processed_frame(processed, w, h)
- # Convert back to original GStreamer pipeline format
- if fmt in ("BGRA", "BGRx"):
- if not hasattr(self, '_probe_bgra_out') or self._probe_bgra_out.shape[:2] != (h, w):
- self._probe_bgra_out = np.empty((h, w, 4), dtype=np.uint8)
- out = self._probe_bgra_out
- out[:, :, :3] = processed
- out[:, :, 3] = frame[:, :, 3] # Keep original alpha
- result = out.tobytes()
- elif fmt == "BGR":
- result = processed.tobytes()
- elif fmt == "RGB":
- result = cv2.cvtColor(processed, cv2.COLOR_BGR2RGB).tobytes()
- elif fmt == "I420":
- result = cv2.cvtColor(
- processed, cv2.COLOR_BGR2YUV_I420
- ).tobytes()
- elif fmt == "NV12":
- # OpenCV has no BGR->NV12; convert to I420 then rearrange
- i420 = cv2.cvtColor(processed, cv2.COLOR_BGR2YUV_I420)
- flat = i420.ravel()
- y_sz = h * w
- uv_sz = y_sz // 4
- if not hasattr(self, '_probe_nv12_out') or self._probe_nv12_out.size != y_sz + uv_sz * 2:
- self._probe_nv12_out = np.empty(y_sz + uv_sz * 2, dtype=np.uint8)
- nv12 = self._probe_nv12_out
- nv12[:y_sz] = flat[:y_sz]
- nv12[y_sz::2] = flat[y_sz : y_sz + uv_sz]
- nv12[y_sz + 1 :: 2] = flat[y_sz + uv_sz :]
- result = nv12.tobytes()
- elif fmt in ("YUY2", "YUYV"):
- if hasattr(cv2, "COLOR_BGR2YUV_YUY2"):
- result = cv2.cvtColor(
- processed, cv2.COLOR_BGR2YUV_YUY2
- ).tobytes()
- else:
- # No effects - fast path: minimise copies
- is_rec = self._video_recorder and self._video_recorder.is_recording
- need_bgr = is_rec or (self._frame_count % 10 == 0)
- bgr_copy = bgr.copy() if need_bgr else None
- if self._vcam_device and fmt in ("BGRA", "BGRx"):
- bgra_direct = bytes(map_info.data)
- if bgr_copy is not None:
- self._distribute_processed_frame(
- bgr_copy, w, h, bgra_direct=bgra_direct,
- )
- else:
- # Vcam only - skip BGR entirely
- self._schedule_vcam_push(bgra_direct, w, h)
- elif bgr_copy is not None:
- self._distribute_processed_frame(bgr_copy, w, h)
- except Exception as e:
- if self._probe_debug_count <= 5:
- log.debug(f"paintable_probe error: {e}")
- finally:
- buf.unmap(map_info)
- if result is not None:
- new_buf = Gst.Buffer.new_wrapped(result)
- new_buf.pts = buf.pts
- new_buf.dts = buf.dts
- new_buf.duration = buf.duration
- new_buf.offset = buf.offset
- info.set_buffer(new_buf)
+ bgr = self._read_bgra_buffer(buf, caps)
+ h, w = bgr.shape[:2]
+ processed = self._apply_frame_processing(bgr) if work else bgr
+ if generation != self._generation:
+ return Gst.PadProbeReturn.OK
+ self._distribute_processed_frame(processed, w, h)
+ if work:
+ # Only used on runtimes that expose PadProbeInfo.set_buffer.
+ # A packed output has a new VideoMeta consistent with its actual stride.
+ output = Gst.Buffer.new_wrapped(cv2.cvtColor(processed, cv2.COLOR_BGR2BGRA).tobytes())
+ output.pts, output.dts, output.duration = buf.pts, buf.dts, buf.duration
+ output.offset, output.offset_end = buf.offset, buf.offset_end
+ GstVideo.buffer_add_video_meta(output, GstVideo.VideoFrameFlags.NONE,
+ GstVideo.VideoFormat.BGRA, w, h)
+ info.set_buffer(output)
+ except Exception:
+ log.exception("Could not process a video buffer")
return Gst.PadProbeReturn.OK
@property
@@ -675,38 +516,22 @@ def prefer_v4l2(self, value: bool) -> None:
self._prefer_v4l2 = value
def capture_snapshot(self, output_path: str) -> bool:
- """Save the current preview frame as a PNG file.
-
- Works for both paintable and appsink pipelines.
- Prioritizes the probe's BGR frame which has all effects and mirroring applied.
- """
- # 1. Try capture from probe's last frame (includes all effects + mirror)
- if self._last_probe_bgr is not None:
- try:
- cv2.imwrite(output_path, self._last_probe_bgr)
- return True
- except Exception as exc:
- log.error("Failed to save probe snapshot: %s", exc)
- # Fall through to fallback methods
-
- # 2. Appsink pipeline fallback: stores last texture directly
- if self._use_appsink and self._last_texture:
- try:
- self._last_texture.save_to_png(output_path)
- return True
- except Exception as exc:
- log.error("Failed to save appsink snapshot: %s", exc)
-
- # 3. Last resort: try paintable directly
- if self._gtksink:
- paintable = self._gtksink.get_property("paintable")
- if paintable and hasattr(paintable, "save_to_png"):
- try:
- paintable.save_to_png(output_path)
- return True
- except Exception:
- pass
- return False
+ """Save a fresh processed frame. Call from a worker, not the GTK main loop."""
+ generation = self._generation
+ self._snapshot_ready.clear()
+ self._snapshot_request.set()
+ try:
+ if not self._snapshot_ready.wait(timeout=1.0):
+ return False
+ frame = self._last_probe_bgr
+ if generation != self._generation or frame is None:
+ return False
+ return bool(cv2.imwrite(output_path, frame.copy()))
+ except (OSError, cv2.error):
+ log.exception("Could not save the captured frame")
+ return False
+ finally:
+ self._snapshot_request.clear()
def play(
self,
@@ -757,6 +582,8 @@ def play(
def _play_continue(self, camera: CameraInfo, fmt: VideoFormat | None, streaming_ready: bool) -> bool:
"""Continuation of play() - may be deferred via GLib.timeout_add.
Always returns False so GLib.timeout_add won't repeat."""
+ if self._current_camera is not camera:
+ return GLib.SOURCE_REMOVE
self._use_appsink = camera.backend in _APPSINK_BACKENDS
log.info(
"play: camera=%s, backend=%s, use_appsink=%s, streaming_ready=%s",
@@ -780,6 +607,7 @@ def _play_continue(self, camera: CameraInfo, fmt: VideoFormat | None, streaming_
return False
# Resolve GStreamer source in background (pw-dump can take seconds)
+ generation = self._generation
def _resolve_source() -> str:
return self._manager.get_gst_source(
camera, fmt, prefer_v4l2=self._prefer_v4l2,
@@ -787,7 +615,7 @@ def _resolve_source() -> str:
def _on_source_resolved(gst_source: str) -> None:
# Guard: camera may have changed while resolving
- if self._current_camera is not camera:
+ if self._current_camera is not camera or generation != self._generation:
return
if not gst_source:
self.emit("error", _("Failed to obtain GStreamer source for this camera."))
@@ -795,7 +623,7 @@ def _on_source_resolved(gst_source: str) -> None:
target_fps = 0
if fmt and fmt.fps:
- target_fps = int(max(fmt.fps))
+ target_fps = max(fmt.fps)
if self._use_appsink:
self._build_appsink_pipeline(gst_source)
@@ -822,6 +650,10 @@ def _build_paintable_pipeline(self, gst_source: str, target_fps: int = 0) -> boo
# as fallback but no longer attempted first - gtk4paintablesink with
# v4l2src provides smoother rendering via GPU texture uploads rather
# than CPU-side GdkMemoryTexture copies (~25 MB/frame).
+ if not hasattr(Gst.PadProbeInfo, "set_buffer") or not Gst.ElementFactory.find("gtk4paintablesink"):
+ log.info("Using the compatible appsink renderer (no pad-buffer replacement API)")
+ return self._build_appsink_pipeline(gst_source)
+
is_phone = self._current_camera and self._current_camera.id.startswith("phone:")
n_threads = min(os.cpu_count() or 2, 4)
@@ -880,7 +712,7 @@ def _build_paintable_pipeline(self, gst_source: str, target_fps: int = 0) -> boo
def _try_start_paintable(self, pipeline_str: str) -> bool:
"""Try to parse and start a paintable pipeline. Returns True on success."""
- log.info("Pipeline (paintable): %s", pipeline_str)
+ log.debug("Building paintable pipeline")
try:
pipeline = Gst.parse_launch(pipeline_str)
except GLib.Error as exc:
@@ -933,7 +765,7 @@ def _try_start_paintable(self, pipeline_str: str) -> bool:
self._probe_id = probe_pad.add_probe(Gst.PadProbeType.BUFFER, self._on_paintable_probe)
self._probe_pad = probe_pad
self._start_fps_counter()
- self.emit("state-changed", "playing")
+ # Playing is announced only after a real video frame arrives.
return True
@@ -1013,7 +845,7 @@ def _build_direct_pipeline(self, gst_source: str, target_fps: int = 0) -> bool:
if camera.device_path:
self._disable_usb_autosuspend(camera.device_path)
self._vcam_resolve_pending = True
- self.emit("state-changed", "playing")
+ # Playing is announced only after a real video frame arrives.
log.info("Direct OpenCV V4L2 preview started (no GStreamer)")
return True
@@ -1069,54 +901,18 @@ def _cv_render_frame(self) -> bool:
return True # continue timer
def _build_appsink_pipeline(self, gst_source: str) -> bool:
- """UDP/MPEG-TS sources (gphoto2, IP) - use appsink with manual texture rendering.
-
- Starts with a delay to let ffmpeg produce frames, then retries if needed.
- """
- log.debug(f"_build_appsink_pipeline: source={gst_source}")
+ self._use_appsink = True
self._appsink_source = gst_source
self._appsink_retry_count = 0
- self._appsink_max_retries = 30 # 30 * 500ms = 15s max wait (like old app)
- self._appsink_timer_id: int | None = None
-
- # BigCam is the sole writer to v4l2loopback so that OpenCV effects
- # are always visible on the virtual camera output. For gPhoto2,
- # the device was pre-allocated in window.py; for IP cameras, we
- # allocate one here.
- pre_allocated = self._current_camera and self._current_camera.extra.get("vcam_device")
- if pre_allocated:
- cam_path = self._current_camera.device_path if self._current_camera else ""
- if pre_allocated != cam_path:
- log.info("Using pre-allocated vcam device %s for effects output", pre_allocated)
- self._start_vcam(pre_allocated)
- else:
- disabled_cams = self._settings.get("vcam-disabled-cameras", []) if hasattr(self, "_settings") else []
- cam_id = self._current_camera.id if self._current_camera else ""
- if cam_id not in disabled_cams:
- self._ensure_vcam_with_retry(cam_id, self._current_camera.name if self._current_camera else None)
-
- # Wait just 100ms for ffmpeg to start producing frames, then try immediately
+ self._appsink_max_retries = 3
self._appsink_timer_id = GLib.timeout_add(100, self._try_appsink_first)
+ # Virtual-camera creation is deferred until an actual frame arrives.
return True
def _ensure_vcam_with_retry(self, cam_id: str, cam_name: str | None) -> bool:
- if not self._current_camera or self._current_camera.id != cam_id:
- return False # Stop retrying if camera changed
-
- loopback_device = VirtualCamera.ensure_ready(
- card_label=cam_name,
- camera_id=cam_id,
- )
- cam_path = self._current_camera.device_path if self._current_camera else ""
- if loopback_device and loopback_device != cam_path:
- self._vcam_alloc_id = cam_id
- self._start_vcam(loopback_device)
- return False # Success, stop retrying
-
- # Failed, retry in 2 seconds
- log.debug("No loopback device for active camera %s, retrying in 2s", cam_name)
- GLib.timeout_add(2000, self._ensure_vcam_with_retry, cam_id, cam_name)
- return False
+ if self._current_camera and self._current_camera.id == cam_id:
+ self._resolve_vcam_async()
+ return GLib.SOURCE_REMOVE
def _try_appsink_first(self) -> bool:
"""First attempt after initial delay, then switch to 500ms retries."""
@@ -1131,109 +927,47 @@ def _try_appsink_first(self) -> bool:
return False # don't repeat the 2s timer
def _try_appsink_pipeline(self) -> bool:
- """Attempt to start the appsink pipeline, retry on failure.
-
- Uses dual pipeline strategy from the old working app:
- Pipeline 1: with address=127.0.0.1 (explicit localhost)
- Pipeline 2: without address (bind to 0.0.0.0)
- """
- # Check if we were stopped while waiting
if self._current_camera is None:
self._appsink_timer_id = None
- return False
-
+ return GLib.SOURCE_REMOVE
self._appsink_retry_count += 1
- gst_source = self._appsink_source
- log.debug(
- f"_try_appsink_pipeline: attempt {self._appsink_retry_count}/{self._appsink_max_retries}"
- )
-
- # Two pipeline variants, exactly as the old working app
- pipeline_attempts = [
- # Pipeline 1: explicit localhost bind
- (
- f"{gst_source} ! "
- f"videoflip name=flip method=0 ! "
- f"videocrop name=crop left=0 right=0 top=0 bottom=0 ! "
- f"video/x-raw,format=BGRA ! "
- f"tee name=t ! "
- f"queue max-size-buffers=2 leaky=downstream silent=true ! "
- f"appsink name=sink emit-signals=True drop=True max-buffers=2 sync=False"
- ),
- # Pipeline 2: fallback without address (bind all interfaces)
- (
- f"{gst_source.replace('address=127.0.0.1 ', '')} ! "
- f"videoflip name=flip method=0 ! "
- f"videocrop name=crop left=0 right=0 top=0 bottom=0 ! "
- f"video/x-raw,format=BGRA ! "
- f"tee name=t ! "
- f"queue max-size-buffers=2 leaky=downstream silent=true ! "
- f"appsink name=sink emit-signals=True drop=True max-buffers=2 sync=False"
- ),
- ]
-
- for i, pipeline_str in enumerate(pipeline_attempts):
- log.debug(f"Trying pipeline {i + 1}: {pipeline_str[:80]}...")
- try:
- pipeline = Gst.parse_launch(pipeline_str)
- except GLib.Error as e:
- log.debug(f"Pipeline {i + 1} parse error: {e}")
- continue
-
- if not isinstance(pipeline, Gst.Pipeline):
- pipe = Gst.Pipeline.new("bigcam")
- pipe.add(pipeline)
- pipeline = pipe
-
- appsink = pipeline.get_by_name("sink")
- if appsink is None:
- log.debug(f"Pipeline {i + 1}: no appsink found")
- pipeline.set_state(Gst.State.NULL)
- continue
- appsink.connect("new-sample", self._on_appsink_sample)
-
+ generation = self._generation
+ pipeline = None
+ try:
+ pipeline = Gst.parse_launch(
+ f"{self._appsink_source} ! videoflip name=flip method=0 ! "
+ "videocrop name=crop left=0 right=0 top=0 bottom=0 ! "
+ "videoconvert ! video/x-raw,format=BGRA ! "
+ "queue max-size-buffers=2 leaky=downstream ! "
+ "appsink name=sink emit-signals=true drop=true max-buffers=2 sync=false")
+ if self._current_camera.backend == BackendType.IP:
+ IPBackend.prepare_pipeline(pipeline)
+ sink = pipeline.get_by_name("sink")
+ sink.connect("new-sample", self._on_appsink_sample, generation)
bus = pipeline.get_bus()
bus.add_signal_watch()
-
- ret = pipeline.set_state(Gst.State.PLAYING)
- if ret == Gst.StateChangeReturn.FAILURE:
- log.debug(f"Pipeline {i + 1}: PLAYING failed immediately")
- pipeline.set_state(Gst.State.NULL)
- continue
-
- # Non-blocking state check - accept ASYNC as success
- ret, state, pending_state = pipeline.get_state(50 * Gst.MSECOND)
- log.debug(f"Pipeline {i + 1}: ret={ret}, state={state}")
- if ret == Gst.StateChangeReturn.FAILURE:
+ watch = bus.connect("message", self._on_bus_message)
+ self._pipeline, self._bus_watch_id = pipeline, watch
+ if pipeline.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
+ raise RuntimeError("The camera pipeline could not start")
+ self._start_fps_counter()
+ self._appsink_timer_id = None
+ return GLib.SOURCE_REMOVE
+ except Exception:
+ log.warning("Camera pipeline setup failed (attempt %s)", self._appsink_retry_count, exc_info=True)
+ if pipeline is not None:
+ bus = pipeline.get_bus()
+ if self._bus_watch_id is not None:
+ bus.disconnect(self._bus_watch_id)
+ bus.remove_signal_watch()
+ self._bus_watch_id = None
pipeline.set_state(Gst.State.NULL)
- continue
-
- if state == Gst.State.PLAYING or ret in (
- Gst.StateChangeReturn.SUCCESS,
- Gst.StateChangeReturn.ASYNC,
- ):
- # Pipeline connected!
- log.debug(f"Pipeline {i + 1}: SUCCESS! Connected.")
- self._pipeline = pipeline
- self._bus_watch_id = bus.connect("message", self._on_bus_message)
- # Install FPS probe on appsink
- sink_pad = appsink.get_static_pad("sink")
- if sink_pad:
- self._probe_id = sink_pad.add_probe(Gst.PadProbeType.BUFFER, self._on_frame_probe)
- self._probe_pad = sink_pad
- self._start_fps_counter()
- self.emit("state-changed", "playing")
- self._appsink_timer_id = None
- return False # stop retrying
-
- pipeline.set_state(Gst.State.NULL)
-
- # All pipelines failed this round
+ self._pipeline = None
if self._appsink_retry_count < self._appsink_max_retries:
- return True # retry in 500ms
- self.emit("error", _("Failed to start camera stream."))
+ return GLib.SOURCE_CONTINUE
self._appsink_timer_id = None
- return False
+ self.emit("error", _("Failed to start camera stream."))
+ return GLib.SOURCE_REMOVE
def _start_pipeline(self) -> bool:
bus = self._pipeline.get_bus()
@@ -1246,10 +980,15 @@ def _start_pipeline(self) -> bool:
self.stop()
return False
- self.emit("state-changed", "playing")
+ # Playing is announced only after a real video frame arrives.
return True
def stop(self, stop_backend: bool = True, keep_vcam: bool = False) -> None:
+ self._generation += 1
+ self._texture_slot.clear()
+ self._has_received_frame = False
+ self._vcam_resolving = False
+ self._snapshot_ready.set()
camera = self._current_camera
self._stop_fps_counter()
self._restore_usb_autosuspend()
@@ -1277,7 +1016,7 @@ def stop(self, stop_backend: bool = True, keep_vcam: bool = False) -> None:
# Phone camera: keep forwarding frames to vcam when keep_vcam is active,
# otherwise disconnect completely.
if self._phone_server_ref is not None:
- if keep_vcam and camera and self._phone_v4l2_device:
+ if keep_vcam and camera and self._vcam_device:
# Detach from preview rendering but keep vcam v4l2 output alive.
# Switch callback to background-only mode (no texture updates).
self._phone_server_ref.set_frame_callback(self._on_phone_frame_bg)
@@ -1350,52 +1089,30 @@ def stop(self, stop_backend: bool = True, keep_vcam: bool = False) -> None:
backend.stop_streaming(camera)
def is_playing(self) -> bool:
- # OpenCV direct capture mode (no GStreamer pipeline)
- if self._cv_cap is not None and self._cv_cap.isOpened():
- return True
- if self._pipeline is None:
- return False
- _, state, _ = self._pipeline.get_state(0)
- return state == Gst.State.PLAYING
+ return self._current_camera is not None and self._has_received_frame
# -- appsink rendering ---------------------------------------------------
_appsink_sample_count = 0
- def _on_appsink_sample(self, appsink: Any) -> Gst.FlowReturn:
+ def _on_appsink_sample(self, appsink: Any, generation: int | None = None) -> Gst.FlowReturn:
+ if generation is None:
+ generation = self._generation
sample = appsink.emit("pull-sample")
- if sample is None:
- return Gst.FlowReturn.OK
- buf = sample.get_buffer()
- caps = sample.get_caps()
- if not buf or not caps:
+ if sample is None or generation != self._generation or self._current_camera is None:
return Gst.FlowReturn.OK
- s = caps.get_structure(0)
- w = s.get_value("width")
- h = s.get_value("height")
- result, map_info = buf.map(Gst.MapFlags.READ)
- if result:
- self._appsink_sample_count += 1
- if self._appsink_sample_count <= 3 or self._appsink_sample_count % 30 == 0:
- log.debug(f"appsink sample #{self._appsink_sample_count}: {w}x{h}")
- data = bytes(map_info.data)
- buf.unmap(map_info)
- # Store BGR frame for tools (QR, smile detection)
- try:
- bgra = np.frombuffer(data, dtype=np.uint8).reshape((h, w, 4))
- bgr = bgra[:, :, :3].copy()
- bgr = self._apply_frame_processing(bgr)
- self._distribute_processed_frame(bgr, w, h)
- except Exception:
- pass
- # Reconstruct BGRA from processed BGR for preview
- if self._has_processing_work() and self._last_probe_bgr is not None:
- display_bgr = self._last_probe_bgr
- bgra_out = cv2.cvtColor(display_bgr, cv2.COLOR_BGR2BGRA)
- data = bgra_out.tobytes()
- stride = len(data) // h
- glib_bytes = GLib.Bytes.new(data)
- GLib.idle_add(self._update_texture, w, h, stride, glib_bytes)
+ try:
+ bgr = self._read_bgra_buffer(sample.get_buffer(), sample.get_caps())
+ bgr = self._apply_frame_processing(bgr)
+ if generation != self._generation:
+ return Gst.FlowReturn.OK
+ h, w = bgr.shape[:2]
+ self._frame_count += 1
+ self._distribute_processed_frame(bgr, w, h)
+ self._notify_first_frame(generation)
+ self._queue_texture(bgr, generation)
+ except Exception:
+ log.exception("Could not process appsink frame")
return Gst.FlowReturn.OK
def _update_texture(
@@ -1428,25 +1145,8 @@ def _apply_anti_flicker_async(self) -> None:
).start()
def _disable_usb_autosuspend(self, device_path: str) -> None:
- """Disable USB autosuspend for the camera device to prevent frame drops."""
- try:
- dev_name = os.path.basename(device_path)
- sysfs_if = os.path.realpath(f"/sys/class/video4linux/{dev_name}/device")
- usb_dev = os.path.dirname(sysfs_if)
- power_path = os.path.join(usb_dev, "power", "control")
- if not os.path.exists(power_path):
- return
- with open(power_path) as f:
- orig = f.read().strip()
- if orig == "on":
- return # already disabled
- self._usb_power_control_path = power_path
- self._usb_power_control_orig = orig
- with open(power_path, "w") as f:
- f.write("on")
- log.info("USB autosuspend disabled: %s (was %s)", power_path, orig)
- except OSError as exc:
- log.debug("Cannot set USB power control: %s", exc)
+ # Power management belongs to the administrator; BigCam does not alter sysfs.
+ return None
def _restore_usb_autosuspend(self) -> None:
"""Restore USB autosuspend to original value after stopping."""
@@ -1463,53 +1163,35 @@ def _restore_usb_autosuspend(self) -> None:
self._usb_power_control_orig = ""
def _resolve_vcam_async(self) -> None:
- """Resolve the virtual camera device in a background thread,
- then start the vcam pipeline on the main thread."""
camera = self._current_camera
- if not camera:
+ if not camera or self._vcam_resolving or self._vcam_device or not VirtualCamera.is_enabled():
return
-
- # Phone cameras (AirPlay/scrcpy) already occupy a v4l2loopback
- # device as their source. Use a separate allocation id so the
- # BigCam Virtual output goes to a *different* device.
+ if camera.id in self._settings.get("vcam-disabled-cameras", []):
+ return
+ generation = self._generation
alloc_id = camera.id
- if camera.id.startswith("phone:"):
- alloc_id = f"vcam:{camera.id}"
-
- def _worker() -> str:
- disabled_cams = self._settings.get("vcam-disabled-cameras", []) if hasattr(self, "_settings") else []
- if alloc_id in disabled_cams:
- return ""
-
- device = VirtualCamera.ensure_ready(
- card_label=camera.name,
- camera_id=alloc_id,
- )
- # Prevent feedback loop: if the vcam device is the same device
- # we're reading from, skip vcam.
- if device and camera.device_path and device == camera.device_path:
- log.warning(
- "vcam device %s is same as camera source - skipping to "
- "avoid feedback loop",
- device,
- )
+ self._vcam_resolving = True
+ def worker():
+ return VirtualCamera.ensure_ready(camera_id=alloc_id, card_label=camera.name)
+ def done(device):
+ if generation == self._generation:
+ self._vcam_resolving = False
+ if not device:
+ return
+ if generation != self._generation or self._current_camera is not camera or not VirtualCamera.is_enabled():
VirtualCamera.release_device(alloc_id)
- return ""
- return device
-
- def _on_done(device: str) -> None:
- # Guard: only start vcam if the camera hasn't changed.
- # Don't check is_playing() - the GStreamer pipeline may still be
- # in ASYNC state transition (PAUSED -> PLAYING) and get_state(0)
- # would return False even though playback is about to start.
- if device and self._current_camera is camera:
- self._vcam_alloc_id = alloc_id
- self._start_vcam(device)
-
- threading.Thread(
- target=lambda: GLib.idle_add(_on_done, _worker()),
- daemon=True,
- ).start()
+ return
+ if device == camera.device_path:
+ VirtualCamera.release_device(alloc_id)
+ log.error("Refusing virtual-camera feedback loop")
+ return
+ self._vcam_alloc_id = alloc_id
+ self._start_vcam(device)
+ def failed(error):
+ if generation == self._generation:
+ self._vcam_resolving = False
+ log.warning("Virtual-camera setup failed: %s", error)
+ run_async(worker, on_success=done, on_error=failed)
def _check_device_busy_async(self, device_path: str) -> None:
"""Check if a device is busy in a background thread."""
@@ -1555,7 +1237,7 @@ def _rebuild_vcam(self, w: int, h: int) -> None:
# downstream v4l2sink stalls or rejects frames.
max_bytes = w * h * 4 * 2 # 2 BGRA frames
pipeline_str = (
- f"appsrc name=src emit-signals=false is-live=true format=time block=false max-bytes={max_bytes} "
+ f"appsrc name=src emit-signals=false is-live=true format=time block=false max-bytes={max_bytes} max-buffers=2 leaky-type=downstream do-timestamp=true "
f"caps=video/x-raw,format=BGRA,width={w},height={h},framerate=30/1 "
f"! queue max-size-buffers=2 leaky=downstream silent=true "
f"! videoconvert n-threads={min(os.cpu_count() or 2, 4)} "
@@ -1670,193 +1352,53 @@ def _push_vcam(self, bgra_bytes: bytes, w: int, h: int) -> None:
self._release_vcam_device()
self._vcam_device = ""
- def _promote_vcam_to_background(self, camera: CameraInfo) -> None:
- """Keep the virtual camera alive when switching away from this camera.
-
- For V4L2 cameras: creates an OpenCV background feeder (or GStreamer
- v4l2src fallback) that reads from the physical camera.
-
- For gPhoto2 cameras: creates a GStreamer pipeline that reads from
- the UDP stream (ffmpeg keeps running) and writes to v4l2loopback.
- """
+ def _promote_vcam_to_background(self, camera):
device = self._vcam_device
- cam_id = camera.id
- # Stop the foreground vcam pipeline but keep the device allocation
- # - the background feeder/pipeline will reuse the same device.
self._stop_vcam()
self._vcam_alloc_id = ""
self._vcam_device = ""
-
- if not device:
- log.debug("promote_vcam_to_background: no vcam device for %s", cam_id)
- return
-
- # gPhoto2 cameras: ffmpeg streams via UDP, create a receiver pipeline
- # that reads the UDP stream and writes to v4l2loopback.
- if camera.backend == BackendType.GPHOTO2:
- udp_port = camera.extra.get("udp_port", 5000)
- self._stop_bg_vcam(cam_id)
- nthreads = min(os.cpu_count() or 2, 4)
- pipeline_str = (
- f"udpsrc port={udp_port} "
- "! tsdemux "
- "! decodebin "
- f"! videoconvert n-threads={nthreads} "
- "! video/x-raw,format=YUY2 "
- f"! v4l2sink device={device} sync=false"
- )
- log.info("Creating background vcam for gphoto2 %s: UDP:%s -> %s",
- camera.name, udp_port, device)
- try:
- pipe = Gst.parse_launch(pipeline_str)
- ret = pipe.set_state(Gst.State.PLAYING)
- if ret == Gst.StateChangeReturn.FAILURE:
- log.warning("Background vcam (gphoto2) failed for %s", cam_id)
- pipe.set_state(Gst.State.NULL)
- else:
- self._bg_vcam_pipelines[cam_id] = pipe
- log.info("Background vcam (gphoto2) active for %s on %s", camera.name, device)
- except GLib.Error as e:
- log.error("Failed to create gphoto2 background vcam: %s", e)
+ if not device or not VirtualCamera.is_enabled():
+ VirtualCamera.release_device(camera.id)
return
-
- # IP cameras: create a pipeline reading the RTSP/HTTP stream
- if camera.backend == BackendType.IP and camera.device_path:
- self._stop_bg_vcam(cam_id)
- backend = self._manager.get_backend(BackendType.IP)
- if backend and hasattr(backend, "get_gst_source"):
- source = backend.get_gst_source(camera)
+ if camera.backend == BackendType.PHONE:
+ from core.frame_output import FrameOutput
+ server = camera.extra.get("phone_server")
+ if server:
+ self._bg_phone_server_ref = server
+ self._bg_phone_cam_id = camera.id
+ self._bg_phone_output = FrameOutput(device)
+ server.set_frame_callback(self._on_phone_frame_bg)
else:
- url = camera.extra.get("url", camera.device_path)
- source = f'souphttpsrc location="{url}" ! decodebin ! videoconvert'
- nthreads = min(os.cpu_count() or 2, 4)
- pipeline_str = (
- f"{source} ! "
- f"videoconvert n-threads={nthreads} ! "
- f"video/x-raw,format=YUY2 ! "
- f"v4l2sink device={device} sync=false"
- )
- log.info("Creating background vcam for IP %s -> %s", camera.name, device)
- try:
- pipe = Gst.parse_launch(pipeline_str)
- ret = pipe.set_state(Gst.State.PLAYING)
- if ret == Gst.StateChangeReturn.FAILURE:
- log.warning("Background vcam (IP) failed for %s", cam_id)
- pipe.set_state(Gst.State.NULL)
- else:
- self._bg_vcam_pipelines[cam_id] = pipe
- log.info("Background vcam (IP) active for %s on %s", camera.name, device)
- except GLib.Error as e:
- log.error("Failed to create IP background vcam: %s", e)
- return
-
- if camera.backend != BackendType.V4L2 or not camera.device_path:
- log.debug("promote_vcam_to_background: skipping non-v4l2 camera %s", cam_id)
- return
-
- # Defer the actual pipeline creation to allow the kernel to fully
- # release the device after OpenCV cap.release() / GStreamer NULL.
- GLib.timeout_add(
- 250,
- self._create_bg_vcam_pipeline,
- cam_id,
- camera,
- device,
- )
-
- def _create_bg_vcam_pipeline(
- self, cam_id: str, camera: CameraInfo, device: str,
- ) -> bool:
- """Create a background virtual camera pipeline (deferred).
-
- Uses OpenCV V4L2 feeder when prefer_v4l2 is active (more reliable
- for USB cameras), otherwise falls back to GStreamer v4l2src pipeline.
-
- Returns False so GLib.timeout_add runs it only once.
- """
- # Guard: if this camera became the active one again (user switched
- # back quickly), don't create a background pipeline - the active
- # effects-aware vcam will handle it.
- if self._current_camera is camera:
- log.debug("_create_bg_vcam: camera %s is active again, skipping", cam_id)
- return False
-
- # Stop any existing background pipeline/feeder for this camera
- self._stop_bg_vcam(cam_id)
-
- # Prefer OpenCV V4L2 feeder for reliable USB camera capture
- if self._prefer_v4l2 and _HAS_CV2 and camera.device_path:
- feeder = _BgVcamFeeder(camera.device_path, device, camera.name)
- if feeder.start():
- self._bg_vcam_feeders[cam_id] = feeder
- return False
- log.warning("OpenCV bg vcam failed for %s, trying GStreamer", cam_id)
-
- # Fallback: GStreamer v4l2src -> v4l2sink
-
- # Build a proper source with format caps using the backend
- # _v4l2_gst_source() already includes jpegdec for MJPEG formats
- backend = self._manager.get_backend(camera.backend)
- fmt_obj = None
- if backend and hasattr(backend, "_pick_best_format") and camera.formats:
- fmt_obj = backend._pick_best_format(camera)
-
- if backend and hasattr(backend, "get_gst_source"):
- source = backend.get_gst_source(camera, fmt_obj)
- elif backend and hasattr(backend, "_v4l2_gst_source"):
- source = backend._v4l2_gst_source(camera.device_path, camera, fmt_obj)
+ VirtualCamera.release_device(camera.id)
else:
- source = f"v4l2src device={camera.device_path}"
+ self.ensure_bg_vcam(camera)
- nthreads = min(os.cpu_count() or 2, 4)
- pipeline_str = (
- f"{source} ! "
- f"videoconvert n-threads={nthreads} ! "
- f"video/x-raw,format=YUY2 ! "
- f"v4l2sink device={device} sync=false"
- )
- log.info("Creating background vcam for %s: source %s -> %s", camera.name, source, device)
- try:
- pipe = Gst.parse_launch(pipeline_str)
- ret = pipe.set_state(Gst.State.PLAYING)
- if ret == Gst.StateChangeReturn.FAILURE:
- log.warning("Background vcam failed to start for %s", cam_id)
- pipe.set_state(Gst.State.NULL)
- return False
- self._bg_vcam_pipelines[cam_id] = pipe
- log.info("Background virtual camera active for %s on %s", camera.name, device)
- except GLib.Error as e:
- log.error("Failed to create background vcam: %s", e)
- return False
+ def _create_bg_vcam_pipeline(self, cam_id, camera, device):
+ # Compatibility callback: actual allocation/setup belongs to a bounded worker.
+ self.ensure_bg_vcam(camera)
+ return GLib.SOURCE_REMOVE
- def _stop_bg_vcam(self, camera_id: str) -> None:
- """Stop a specific background virtual camera pipeline or feeder."""
+ def _stop_bg_vcam(self, camera_id):
pipe = self._bg_vcam_pipelines.pop(camera_id, None)
if pipe:
- log.info("Stopping background vcam pipeline for %s", camera_id)
+ pipe.get_bus().remove_signal_watch()
pipe.set_state(Gst.State.NULL)
feeder = self._bg_vcam_feeders.pop(camera_id, None)
if feeder:
feeder.stop()
-
- def stop_all_bg_vcams(self) -> None:
- """Stop all background virtual camera pipelines (used on app close)."""
- for cam_id in list(self._bg_vcam_pipelines):
- pipe = self._bg_vcam_pipelines.pop(cam_id, None)
- if pipe:
- pipe.set_state(Gst.State.NULL)
- self._bg_vcam_pipelines.clear()
- for cam_id in list(self._bg_vcam_feeders):
- feeder = self._bg_vcam_feeders.pop(cam_id, None)
- if feeder:
- feeder.stop()
- self._bg_vcam_feeders.clear()
- # Also stop background phone vcam forwarding
+ if self._bg_phone_cam_id == camera_id:
+ self._stop_bg_phone_vcam()
+ if not self._current_camera or self._current_camera.id != camera_id:
+ VirtualCamera.release_device(camera_id)
+
+ def stop_all_bg_vcams(self):
+ self._bg_generation += 1
+ for camera_id in set(self._bg_vcam_pipelines) | set(self._bg_vcam_feeders):
+ self._stop_bg_vcam(camera_id)
self._stop_bg_phone_vcam()
- def has_active_bg_vcams(self) -> bool:
- """Return True if any background virtual cameras are running."""
- return bool(self._bg_vcam_feeders) or bool(self._bg_vcam_pipelines)
+ def has_active_bg_vcams(self):
+ return bool(self._bg_vcam_pipelines or self._bg_vcam_feeders or self._bg_phone_cam_id)
@property
def vcam_active(self) -> bool:
@@ -1869,106 +1411,58 @@ def stop_vcam(self) -> None:
self._release_vcam_device()
self._vcam_device = ""
- def ensure_bg_vcam(self, camera: CameraInfo) -> None:
- """Ensure a background vcam feeder exists for the given camera.
-
- Called at detection time for each camera. Creates backend-specific
- background virtual camera pipelines for V4L2, IP, and phone cameras
- (scrcpy/airplay). gPhoto2 and WebSocket phone cameras get the device
- allocated (visible in apps) but streaming starts only when selected.
- """
- if not VirtualCamera.is_enabled():
- return
-
- disabled_cams = self._settings.get("vcam-disabled-cameras", []) if hasattr(self, "_settings") else []
- if camera.id in disabled_cams:
- return
-
- # Skip if this camera is already the active one (effects pipeline handles vcam)
- if self._current_camera and self._current_camera.id == camera.id:
- return
- # Skip if already has a background feeder or pipeline
- if camera.id in self._bg_vcam_feeders or camera.id in self._bg_vcam_pipelines:
- return
- # Skip if bg phone vcam is already forwarding for this camera
- if self._bg_phone_cam_id == camera.id:
- return
- # Skip if device is already allocated for this camera
- if VirtualCamera.get_device_for_camera(camera.id):
- return
-
- # gPhoto2 and WebSocket phone cameras: allocate device at detection
- # so it shows up in apps, but don't start streaming (requires user selection).
- if camera.backend == BackendType.GPHOTO2 or camera.backend == BackendType.PHONE:
- device = VirtualCamera.ensure_ready(
- card_label=camera.name, camera_id=camera.id,
- )
- if device:
- log.info("Pre-allocated vcam %s for %s %s (streaming starts on selection)",
- device, camera.backend.name, camera.name)
- return
-
- if not camera.device_path:
- # IP, Libcamera, and Pipewire cameras often do not have block device paths,
- # but they still have valid GStreamer sources.
- if camera.backend not in (BackendType.IP, BackendType.LIBCAMERA, BackendType.PIPEWIRE):
+ def ensure_bg_vcam(self, camera):
+ if (not VirtualCamera.is_enabled() or camera.id in self._settings.get("vcam-disabled-cameras", [])
+ or self._current_camera and self._current_camera.id == camera.id
+ or camera.id in self._bg_vcam_pipelines or camera.id in self._bg_vcam_feeders
+ or camera.id in self._bg_pending or self._bg_phone_cam_id == camera.id):
+ return GLib.SOURCE_REMOVE
+ # Browser frames have an explicit producer callback, installed on promotion.
+ # Do not turn on an unselected DSLR just because USB discovery found it.
+ if camera.backend in (BackendType.PHONE, BackendType.GPHOTO2):
+ backend = self._manager.get_backend(camera.backend)
+ if camera.backend == BackendType.PHONE or not backend or not backend.is_camera_streaming(camera):
+ return GLib.SOURCE_REMOVE
+ generation = self._bg_generation
+ self._bg_pending.add(camera.id)
+ def create():
+ device = VirtualCamera.ensure_ready(camera_id=camera.id)
+ if not device:
+ raise RuntimeError("No authorized virtual camera output is available")
+ if camera.device_path == device:
+ raise RuntimeError("Virtual output cannot read itself")
+ source = self._manager.get_gst_source(camera, prefer_v4l2=True)
+ if not source:
+ raise RuntimeError("Camera has no streaming source")
+ pipe = Gst.parse_launch(
+ f"{source} ! queue max-size-buffers=2 leaky=downstream ! videoconvert ! "
+ f"video/x-raw,format=YUY2 ! v4l2sink device={gst_quote(device)} sync=false")
+ if pipe.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
+ pipe.set_state(Gst.State.NULL)
+ raise RuntimeError("Background camera pipeline failed")
+ return pipe
+ def done(pipe):
+ self._bg_pending.discard(camera.id)
+ is_active = self._current_camera and self._current_camera.id == camera.id
+ exists = any(cam.id == camera.id for cam in self._manager.cameras)
+ if generation != self._bg_generation or is_active or not exists or not VirtualCamera.is_enabled():
+ pipe.set_state(Gst.State.NULL)
+ if not is_active:
+ VirtualCamera.release_device(camera.id)
return
-
- # Allocate a v4l2loopback device
- device = VirtualCamera.ensure_ready(
- card_label=camera.name, camera_id=camera.id,
- )
- if not device:
- log.debug("ensure_bg_vcam: no loopback device for %s, retrying in 2s", camera.name)
- GLib.timeout_add(2000, self.ensure_bg_vcam, camera)
- return
-
- # IP cameras: create a GStreamer pipeline reading the stream
- if camera.backend == BackendType.IP:
- backend = self._manager.get_backend(BackendType.IP)
- if backend and hasattr(backend, "get_gst_source"):
- source = backend.get_gst_source(camera)
- else:
- url = camera.extra.get("url", camera.device_path)
- source = f'souphttpsrc location="{url}" ! decodebin ! videoconvert'
- nthreads = min(os.cpu_count() or 2, 4)
- pipeline_str = (
- f"{source} ! "
- f"videoconvert n-threads={nthreads} ! "
- f"video/x-raw,format=YUY2 ! "
- f"v4l2sink device={device} sync=false"
- )
- log.info("Background vcam for IP %s -> %s", camera.name, device)
- try:
- pipe = Gst.parse_launch(pipeline_str)
- ret = pipe.set_state(Gst.State.PLAYING)
- if ret == Gst.StateChangeReturn.FAILURE:
- log.warning("Background vcam (IP) failed for %s", camera.id)
- pipe.set_state(Gst.State.NULL)
- else:
- self._bg_vcam_pipelines[camera.id] = pipe
- log.info("Background vcam (IP) active for %s on %s", camera.name, device)
- except GLib.Error as e:
- log.error("Failed to create IP background vcam: %s", e)
- return
-
- # V4L2 cameras (including scrcpy/airplay phone cameras whose
- # v4l2loopback device is readable)
- if camera.backend == BackendType.V4L2 and camera.device_path:
- # Start the background feeder
- if _HAS_CV2:
- feeder = _BgVcamFeeder(camera.device_path, device, camera.name)
- if feeder.start():
- self._bg_vcam_feeders[camera.id] = feeder
- log.info("Background vcam started at detection for %s on %s", camera.name, device)
- return
- log.warning("OpenCV bg vcam failed at detection for %s", camera.name)
- # Fallback: GStreamer v4l2src pipeline
- self._create_bg_vcam_pipeline(camera.id, camera, device)
-
- elif camera.backend in (BackendType.LIBCAMERA, BackendType.PIPEWIRE):
- # Libcamera and Pipewire natively use GStreamer pipelines
- self._create_bg_vcam_pipeline(camera.id, camera, device)
+ self._bg_vcam_pipelines[camera.id] = pipe
+ bus = pipe.get_bus()
+ bus.add_signal_watch()
+ bus.connect("message::error", lambda *_: self._stop_bg_vcam(camera.id))
+ bus.connect("message::eos", lambda *_: self._stop_bg_vcam(camera.id))
+ def failed(exc):
+ self._bg_pending.discard(camera.id)
+ if not self._current_camera or self._current_camera.id != camera.id:
+ VirtualCamera.release_device(camera.id)
+ log.warning("Background camera setup failed: %s", exc)
+ # Retry is explicit, not an unbounded timer or repeated Polkit prompt.
+ run_async(create, on_success=done, on_error=failed)
+ return GLib.SOURCE_REMOVE
# -- phone camera --------------------------------------------------------
@@ -1989,34 +1483,16 @@ def ensure_bg_vcam(self, camera: CameraInfo) -> None:
_bg_phone_cam_id: str = ""
def _start_phone_camera(self, camera: CameraInfo) -> bool:
- """Receive frames from the phone camera WebSocket server."""
server = camera.extra.get("phone_server")
if not server:
self.emit("error", _("Phone camera server not available."))
return False
- # Transition from background to foreground: clear bg state.
- # The set_frame_callback below replaces the bg callback on the server.
- if self._bg_phone_server_ref is server:
- self._bg_phone_server_ref = None
- self._bg_phone_cam_id = ""
- self._use_appsink = True # use texture-based rendering
+ self._stop_bg_phone_vcam()
+ self._use_appsink = True
self._phone_server_ref = server
- server.set_frame_callback(self._on_phone_frame)
-
- # Start v4l2loopback output if virtual camera is enabled
- disabled_cams = self._settings.get("vcam-disabled-cameras", []) if hasattr(self, "_settings") else []
- cam_id = camera.id if camera else ""
- if cam_id not in disabled_cams:
- loopback_device = VirtualCamera.ensure_ready(
- card_label=camera.name if camera else None,
- camera_id=cam_id,
- )
- if loopback_device:
- self._start_phone_v4l2(loopback_device)
-
+ generation = self._generation
+ server.set_frame_callback(lambda frame: self._on_phone_frame(frame, generation))
self._start_fps_counter()
- self.emit("state-changed", "playing")
- log.info("Phone camera started - waiting for frames")
return True
def _start_phone_v4l2(self, device: str) -> None:
@@ -2038,7 +1514,7 @@ def _rebuild_phone_v4l2(self, w: int, h: int) -> None:
log.warning("Cannot rebuild phone v4l2: no device set")
return
pipeline_str = (
- "appsrc name=src emit-signals=false is-live=true format=time "
+ "appsrc name=src emit-signals=false is-live=true format=time block=false max-buffers=2 leaky-type=downstream do-timestamp=true "
f"caps=video/x-raw,format=BGR,width={w},height={h},framerate=30/1 "
f"! videoconvert n-threads={min(os.cpu_count() or 2, 4)} "
"! video/x-raw,format=YUY2 "
@@ -2126,40 +1602,20 @@ def _push_phone_v4l2(self, bgr, w: int, h: int) -> None:
if ret != Gst.FlowReturn.OK:
log.warning("Phone v4l2: push-buffer returned %s", ret)
- def _on_phone_frame(self, bgr: Any) -> None:
- """Handle a BGR frame from the phone WebSocket (asyncio thread)."""
- if self._current_camera is None:
+ def _on_phone_frame(self, bgr: Any, generation: int | None = None) -> None:
+ if generation is None:
+ generation = self._generation
+ if self._current_camera is None or generation != self._generation:
return
- # Drop frame if GTK hasn't consumed the previous one
- if self._phone_frame_pending:
+ bgr = self._apply_frame_processing(bgr)
+ if generation != self._generation:
return
- self._phone_frame_pending = True
-
h, w = bgr.shape[:2]
-
- # Apply effects (mirror is handled via CSS on preview, not on data)
- if self._effects.has_active_effects():
- bgr = self._effects.apply(bgr)
-
- # Store for snapshot/tools - mirror for photo/recording
- self._last_probe_bgr = cv2.flip(bgr, 1) if self._mirror else bgr
-
- # Write to video recorder if active (with mirror for consistency with preview)
- rec = self._video_recorder
- if rec and rec.is_recording:
- rec.write_frame(self._last_probe_bgr)
-
- # Feed virtual camera via appsrc if active
- if self._phone_v4l2_device:
- self._push_phone_v4l2(bgr, w, h)
-
- # BGR -> BGRA using OpenCV SIMD (much faster than numpy manual copy)
- bgra = cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA)
- data = bgra.tobytes()
-
- stride = w * 4
- glib_bytes = GLib.Bytes.new(data)
- GLib.idle_add(self._update_phone_texture, w, h, stride, glib_bytes)
+ # Mirror Preview is a display preference, not a destructive media transform.
+ self._frame_count += 1
+ self._distribute_processed_frame(bgr, w, h)
+ self._notify_first_frame(generation)
+ self._queue_texture(bgr, generation)
def _update_phone_texture(
self, w: int, h: int, stride: int, glib_bytes: GLib.Bytes
@@ -2175,21 +1631,21 @@ def _update_phone_texture(
pass
return False
- def _on_phone_frame_bg(self, bgr: Any) -> None:
- """Background-only phone frame handler - feeds v4l2 vcam without preview."""
- if not self._phone_v4l2_device:
- return
- h, w = bgr.shape[:2]
- self._push_phone_v4l2(bgr, w, h)
+ def _on_phone_frame_bg(self, bgr):
+ output = getattr(self, "_bg_phone_output", None)
+ if output is not None:
+ output.push(bgr)
- def _stop_bg_phone_vcam(self) -> None:
- """Stop background phone virtual camera forwarding."""
+ def _stop_bg_phone_vcam(self):
if self._bg_phone_server_ref is not None:
self._bg_phone_server_ref.set_frame_callback(None)
self._bg_phone_server_ref = None
+ output = getattr(self, "_bg_phone_output", None)
+ self._bg_phone_output = None
+ if output:
+ output.stop()
if self._bg_phone_cam_id:
- self._stop_phone_v4l2()
- self._phone_v4l2_device = ""
+ VirtualCamera.release_device(self._bg_phone_cam_id)
self._bg_phone_cam_id = ""
# -- bus handling --------------------------------------------------------
@@ -2323,3 +1779,44 @@ def _try_pw_fallback(self) -> bool:
self._start_vcam(loopback_device)
return True
return False
+
+ @staticmethod
+ def _read_bgra_buffer(buffer, caps):
+ video = GstVideo.VideoInfo.new_from_caps(caps)
+ if video.finfo.name != "BGRA":
+ raise ValueError("The processing branch must negotiate BGRA")
+ meta = GstVideo.buffer_get_video_meta(buffer)
+ stride = meta.stride[0] if meta else video.stride[0]
+ offset = meta.offset[0] if meta else video.offset[0]
+ ok, mapping = buffer.map(Gst.MapFlags.READ)
+ if not ok:
+ raise ValueError("Could not map video buffer")
+ try:
+ return bgr_from_bgra(mapping.data, video.width, video.height, stride, offset)
+ finally:
+ buffer.unmap(mapping)
+
+ def _notify_first_frame(self, generation):
+ if generation != self._generation or self._has_received_frame:
+ return
+ self._has_received_frame = True
+ def announce():
+ if generation == self._generation and self._current_camera is not None:
+ self.emit("state-changed", "playing")
+ self._resolve_vcam_async()
+ return GLib.SOURCE_REMOVE
+ GLib.idle_add(announce)
+
+ def _queue_texture(self, bgr, generation):
+ h, w = bgr.shape[:2]
+ data = cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA).tobytes()
+ if self._texture_slot.publish((generation, w, h, data)):
+ GLib.idle_add(self._flush_texture)
+
+ def _flush_texture(self):
+ value = self._texture_slot.take()
+ if value is not None:
+ generation, w, h, data = value
+ if generation == self._generation and self._current_camera is not None:
+ self._update_texture(w, h, w * 4, GLib.Bytes.new(data))
+ return GLib.SOURCE_REMOVE
diff --git a/usr/share/biglinux/bigcam/core/video_recorder.py b/usr/share/biglinux/bigcam/core/video_recorder.py
index b6f4e29..9d1bc72 100644
--- a/usr/share/biglinux/bigcam/core/video_recorder.py
+++ b/usr/share/biglinux/bigcam/core/video_recorder.py
@@ -1,516 +1,403 @@
-"""Video recorder – GStreamer-based video recording with audio."""
+"""Bounded video recording with a single pipeline owner and confirmed EOS.
+The GTK thread never builds, blocks on or finalizes a recording pipeline.
+Preview may drop frames; recording reports its own dropped-frame count and uses
+capture timestamps, rather than speeding up the movie when the consumer is slow.
+"""
from __future__ import annotations
+import json
+import logging
+import math
import os
+import queue
import subprocess
-import time
-import logging
import threading
+import time
from typing import Any
-try:
- import cv2
- _HAS_CV2 = True
-except ImportError:
- _HAS_CV2 = False
-
+import cv2
import gi
gi.require_version("Gst", "1.0")
-
-from gi.repository import Gst, GLib
-
-from core.camera_backend import CameraInfo
-from core.camera_manager import CameraManager
+from gi.repository import GLib, GObject, Gst
from utils import xdg
+from utils.gst_buffers import bgr_buffer
+from utils.media_paths import reserve_media_path, reserve_named_path
+from utils.urls import gst_quote
+from utils.video_formats import frame_rate
+
+from core.recording_config import RecordingConfig
log = logging.getLogger(__name__)
-class VideoRecorder:
- """Records video+audio using a unified GStreamer pipeline fed by appsrc.
- This ensures that processed frames (with effects) from StreamEngine are
- captured correctly.
+
+class VideoRecorder(GObject.Object):
+ """State: idle -> starting -> recording -> finalizing -> idle/error.
+
+ ``finalized`` is emitted only after EOS, NULL and a nonempty file have been
+ observed (or with success=False and an actionable error). The caller must
+ retain the application until this signal arrives. No remux discards the
+ original recording, and a second start cannot overwrite a pending session.
"""
+ __gsignals__ = {
+ "state-changed": (GObject.SignalFlags.RUN_LAST, None, (str,)),
+ "finalized": (GObject.SignalFlags.RUN_LAST, None, (str, bool, str)),
+ }
- def __init__(self, camera_manager: CameraManager) -> None:
+ def __init__(self, camera_manager=None):
+ super().__init__()
self._manager = camera_manager
- self._recording = False
+ self._lock = threading.RLock()
+ self._config = RecordingConfig()
+ self._session_config = self._config
+ self._state = "idle"
self._output_path = ""
- self._pipeline: Gst.Pipeline | None = None
- self._vsrc: Gst.Element | None = None
- self._audio_srcs: list[Gst.Element] = []
- self._audio_vol_elements: dict[str, Gst.Element] = {}
- self._global_muted: bool = False
- self._w = 0
- self._h = 0
- self._start_time = 0
- self._finalize_thread: threading.Thread | None = None
- # Configurable codec/container/bitrate
- self._video_codec = "h264"
- self._audio_codec = "opus"
- self._container = "mkv"
- self._video_bitrate = 8000
-
- def configure(
- self,
- video_codec: str = "h264",
- audio_codec: str = "opus",
- container: str = "mkv",
- video_bitrate: int = 8000,
- ) -> None:
- """Set recording codec/container/bitrate preferences."""
- self._video_codec = video_codec
- self._audio_codec = audio_codec
- self._container = container
- self._video_bitrate = max(500, min(50000, video_bitrate))
+ self._frames = queue.Queue(maxsize=4)
+ self._stop_event = threading.Event()
+ self._worker = None
+ self._done = threading.Event()
+ self._done.set()
+ self._muted = False
+ self._volumes = {}
+ self._active = set()
+ self._audio_devices = []
+ self._external_audio = {}
+ self._pcm_sources = {}
+ self._audio_processes = []
+ self.dropped_frames = 0
+ self.frames_written = 0
@property
- def is_recording(self) -> bool:
- return self._recording
+ def state(self):
+ with self._lock:
+ return self._state
@property
- def output_path(self) -> str:
- return self._output_path
+ def is_recording(self):
+ return self.state in {"starting", "recording"}
+
+ @property
+ def is_finalizing(self):
+ return self.state == "finalizing"
- def start(
- self,
- camera: CameraInfo,
- pipeline: Gst.Pipeline | None = None,
- filename: str | None = None,
- mirror: bool = False,
- record_audio: bool = True,
- audio_sources: list[str] | None = None,
- active_audio_sources: list[str] | None = None,
- source_volumes: dict[str, float] | None = None,
- muted: bool = False,
- ) -> str | None:
- """Initialize recording. The actual pipeline starts on the first frame.
-
- Args:
- audio_sources: All PulseAudio source device names from AudioMonitor
- to include in the recording pipeline.
- active_audio_sources: Subset of audio_sources that are currently
- active (unmuted). Others start muted.
- source_volumes: Per-source volume levels {source_name: 0.0–1.0}.
- muted: Whether global mute is currently active.
- """
- if self._recording:
- return None
-
- if filename is None:
- timestamp = time.strftime("%Y%m%d_%H%M%S")
- ext = self._container_ext()
- filename = f"bigcam_{timestamp}{ext}"
-
- output_dir = xdg.videos_dir()
- os.makedirs(output_dir, exist_ok=True)
- self._output_path = os.path.join(output_dir, filename)
- self._record_audio = record_audio
- self._audio_source_devices = audio_sources or []
- self._active_audio_set = set(active_audio_sources or [])
- self._source_volumes: dict[str, float] = dict(source_volumes or {})
- self._global_muted = muted
- self._recording = True
- self._w = 0
- self._h = 0
- self._pipeline = None
- self._vsrc = None
- self._audio_srcs = []
- self._audio_vol_elements = {}
- self._start_time = time.time()
-
- log.info("Recording initialized: %s (muted=%s)", self._output_path, muted)
+ @property
+ def output_path(self):
return self._output_path
- def _pick_encoder_str(self) -> str:
- """Return the encoder element string based on configured codec.
-
- Encoder selection priority: NVENC → VAAPI (va) → VAAPI (legacy) → Software.
- Settings aligned with big-video-converter defaults (profile high, CQP/CRF).
- """
- br = self._video_bitrate
- codec = self._video_codec
-
- # WebM only supports VP8/VP9
- if self._container == "webm" and codec != "vp9":
- log.info("Container webm requires VP9; overriding codec %s", codec)
- codec = "vp9"
- # MP4 doesn't support VP9 or MJPEG
- elif self._container == "mp4" and codec in ("vp9", "mjpeg"):
- log.info("Container mp4 incompatible with %s; falling back to h264", codec)
- codec = "h264"
-
- if codec == "h265":
- hw = [
- ("nvh265enc", f"preset=hq rc-mode=vbr bitrate={br}"),
- ("vah265enc", "rate-control=cqp qp-i=28 qp-p=28 qp-b=30"),
- ("vaapih265enc", "rate-control=cqp init-qp=28"),
- ]
- for name, props in hw:
- if Gst.ElementFactory.find(name):
- log.info("Using hardware H.265 encoder: %s", name)
- return f"{name} {props} ! h265parse"
- log.info("Using software H.265 encoder: x265enc")
- return "x265enc speed-preset=4 tune=0 option-string=crf=28:log-level=warning ! h265parse"
-
- if codec == "vp9":
- hw = [
- ("nvvp9enc", f"preset=hq rc-mode=vbr bitrate={br}"),
- ("vavp9enc", "rate-control=cqp qp=28"),
- ("vaapivp9enc", "rate-control=cqp"),
- ]
- for name, props in hw:
- if Gst.ElementFactory.find(name):
- log.info("Using hardware VP9 encoder: %s", name)
- return f"{name} {props}"
- log.info("Using VP9 encoder: vp9enc")
- return f"vp9enc target-bitrate={br * 1000} cpu-used=4 deadline=1 threads=4 end-usage=cq cq-level=28"
-
- if codec == "mjpeg":
- hw = [
- ("vaapijpegenc", "quality=90"),
- ]
- for name, props in hw:
- if Gst.ElementFactory.find(name):
- log.info("Using hardware MJPEG encoder: %s", name)
- return f"{name} {props}"
- log.info("Using software MJPEG encoder: jpegenc")
- return "jpegenc quality=90"
-
- # Default: H.264
- # Priority: NVENC → VA-API (new) → VA-API (legacy) → x264enc
- # Matches big-video-converter: profile high, level 4.1, CQP/CRF mode
- hw = [
- ("nvh264enc", f"preset=hq rc-mode=vbr bitrate={br}"),
- ("vah264enc", "rate-control=cqp qp-i=24 qp-p=24 qp-b=26"),
- ("vaapih264enc", "rate-control=cqp init-qp=24"),
- ]
- for name, props in hw:
- if Gst.ElementFactory.find(name):
- log.info("Using hardware H.264 encoder: %s", name)
- caps = "video/x-h264,profile=high"
- return f"{name} {props} ! {caps} ! h264parse"
- log.info("Using software H.264 encoder: x264enc")
- return (
- "x264enc speed-preset=4 pass=5 quantizer=24 "
- "key-int-max=120 bframes=3 threads=0 "
- "! video/x-h264,profile=high ! h264parse"
- )
-
- def _pick_audio_encoder_str(self) -> str:
- """Return the audio encoder element string based on configured codec."""
- codec = self._audio_codec
-
- # WebM only supports Opus/Vorbis
- if self._container == "webm" and codec not in ("opus", "vorbis"):
- log.info("Container webm requires Opus/Vorbis; overriding audio %s", codec)
- codec = "opus"
- # MP4 doesn't support Vorbis
- elif self._container == "mp4" and codec == "vorbis":
- log.info("Container mp4 incompatible with vorbis; falling back to opus")
- codec = "opus"
-
- if codec == "aac":
- for name in ("fdkaacenc", "avenc_aac", "voaacenc"):
- if Gst.ElementFactory.find(name):
- log.info("Using AAC encoder: %s", name)
- return name
- log.warning("No AAC encoder found, falling back to opusenc")
- return "opusenc"
- if codec == "mp3":
- log.info("Using MP3 encoder: lamemp3enc")
- return "lamemp3enc"
- if codec == "vorbis":
- log.info("Using Vorbis encoder: vorbisenc")
- return "vorbisenc"
- # Default: Opus
- return "opusenc"
-
- def _pick_muxer_str(self) -> str:
- """Return the muxer element string based on configured container."""
- container = self._container
- if container == "webm":
- return "webmmux"
- if container == "mp4":
- return "mp4mux"
- return "matroskamux"
-
- def _container_ext(self) -> str:
- """Return file extension for the configured container."""
- return {"webm": ".webm", "mp4": ".mp4"}.get(self._container, ".mkv")
-
- def _ensure_pipeline(self, w: int, h: int) -> bool:
- if self._pipeline:
- return True
-
- self._w = w
- self._h = h
- enc_str = self._pick_encoder_str()
- audio_enc = self._pick_audio_encoder_str()
- muxer = self._pick_muxer_str()
- audio_str = ""
- if self._record_audio:
- extra_devs = self._audio_source_devices
- # Determine initial volume for the system mic (respects global mute)
- mic_vol = 0.0 if self._global_muted else 1.0
-
- # Pipeline setup: always use audiomixer to combine system mic + cameras
- # All sources use provide-clock=false to use system/global pipeline clock
- # audiomixer latency handles sync between sources
- audio_str = (
- "audiomixer name=amix latency=500000000 ! "
- "queue max-size-time=2000000000 leaky=downstream ! audioconvert ! "
- f"audioresample ! audiorate ! {audio_enc} ! mux. "
- )
-
- # System Microfone (Default source)
- # do-timestamp=true: use pipeline clock for timestamps
- # provide-clock=false: don't compete for clock master
- audio_str += (
- "pulsesrc do-timestamp=true provide-clock=false "
- "buffer-time=200000 latency-time=50000 "
- "name=asrc_mic ! "
- "queue max-size-time=1000000000 leaky=downstream ! "
- "audioconvert ! audioresample ! "
- f"volume name=avol_mic volume={mic_vol} ! amix. "
- )
-
- if extra_devs:
- for i, dev in enumerate(extra_devs):
- safe = dev.replace('"', '\\"')
- if dev in self._active_audio_set and not self._global_muted:
- vol = self._source_volumes.get(dev, 1.0)
- else:
- vol = 0.0
-
- # USB sources follow global clock
- audio_str += (
- f'pulsesrc device="{safe}" do-timestamp=true '
- f'provide-clock=false '
- f'buffer-time=500000 latency-time=100000 '
- f'name=asrc_{i} ! '
- f'queue max-size-time=2000000000 max-size-buffers=0 max-size-bytes=0 ! '
- f'audioconvert ! audioresample ! '
- f'volume name=avol_{i} volume={vol} ! amix. '
- )
-
- escaped = self._output_path.replace('"', '\\"')
- pipeline_str = (
- f"appsrc name=vsrc format=time is-live=true do-timestamp=true "
- f"caps=video/x-raw,format=BGR,width={w},height={h},framerate=30/1 ! "
- f"queue max-size-buffers=30 max-size-time=1000000000 leaky=downstream ! "
- f"videoconvert ! {enc_str} ! "
- f"{muxer} name=mux ! filesink location=\"{escaped}\" "
- f"{audio_str}"
- )
-
- log.info("Recording pipeline: %s", pipeline_str)
- log.info(
- "Audio sources: all=%s active=%s volumes=%s",
- self._audio_source_devices,
- list(self._active_audio_set),
- self._source_volumes,
- )
+ def _set_state(self, state):
+ with self._lock:
+ self._state = state
+ GLib.idle_add(self._emit_state, state)
+
+ def _emit_state(self, state):
+ self.emit("state-changed", state)
+ return GLib.SOURCE_REMOVE
+
+ def configure(self, video_codec="h264", audio_codec="opus", container="mkv", video_bitrate=8000):
+ config = RecordingConfig(video_codec, audio_codec, container, video_bitrate)
+ with self._lock:
+ self._config = config # Applies only to the next recording.
+
+ def start(self, camera, pipeline=None, filename=None, mirror=False,
+ record_audio=True, audio_sources=None, active_audio_sources=None,
+ source_volumes=None, muted=False, fps=30.0, external_audio: dict[str, int | None] | None = None):
+ del camera, pipeline, mirror # The preview mirror never changes saved pixels.
+ with self._lock:
+ if not self._done.is_set():
+ return None
+ self._session_config = config = self._config
+ self._fps = frame_rate(fps if fps and fps > 0 else 30)
+ self._output_path = (reserve_named_path(xdg.videos_dir(), filename)
+ if filename else reserve_media_path(xdg.videos_dir(), config.extension))
+ self._audio_devices = list(dict.fromkeys(audio_sources or [])) if record_audio else []
+ self._external_audio = dict(external_audio or {})
+ self._pcm_sources = {}
+ self._audio_processes = []
+ self._active = set(active_audio_sources or [])
+ self._volumes = dict(source_volumes or {})
+ self._muted = bool(muted)
+ self._frames = queue.Queue(maxsize=4)
+ self._stop_event = threading.Event()
+ self._done.clear()
+ self.frames_written = self.dropped_frames = 0
+ self._set_state("starting")
+ self._worker = threading.Thread(target=self._record, name="bigcam-recording", daemon=False)
+ self._worker.start()
+ return self._output_path
+
+ def write_frame(self, bgr: Any):
+ if not self.is_recording or self._stop_event.is_set():
+ return
+ if bgr is None or bgr.ndim != 3 or bgr.shape[2] != 3:
+ return
+ item = (time.monotonic_ns(), bgr.copy())
try:
- self._pipeline = Gst.parse_launch(pipeline_str)
- self._vsrc = self._pipeline.get_by_name("vsrc")
-
- # Collect all pulsesrc elements for EOS on stop
- self._audio_srcs = []
- mic = self._pipeline.get_by_name("asrc_mic")
- if mic:
- self._audio_srcs.append(mic)
- for i in range(len(self._audio_source_devices)):
- el = self._pipeline.get_by_name(f"asrc_{i}")
- if el:
- self._audio_srcs.append(el)
-
- # Collect volume elements for dynamic mute/unmute
- self._audio_vol_elements = {}
- mic_vol_el = self._pipeline.get_by_name("avol_mic")
- if mic_vol_el:
- self._audio_vol_elements["__mic__"] = mic_vol_el
-
- for i, dev in enumerate(self._audio_source_devices):
- vol_el = self._pipeline.get_by_name(f"avol_{i}")
- if vol_el:
- self._audio_vol_elements[dev] = vol_el
- log.info(
- "avol_%d (%s): volume=%.1f",
- i, dev, vol_el.get_property("volume"),
- )
-
- bus = self._pipeline.get_bus()
- bus.add_signal_watch()
- bus.connect("message::error", self._on_error)
-
- ret = self._pipeline.set_state(Gst.State.PLAYING)
- if ret == Gst.StateChangeReturn.FAILURE:
- log.error("Failed to start recording pipeline")
- self._stop_pipeline()
- return False
- return True
- except Exception as exc:
- log.error("Failed to create recording pipeline: %s", exc)
- return False
-
- def set_source_active(self, source_name: str, active: bool) -> None:
- """Mute or unmute a USB camera audio source in the recording pipeline."""
- if active:
- self._active_audio_set.add(source_name)
- else:
- self._active_audio_set.discard(source_name)
- vol_el = self._audio_vol_elements.get(source_name)
- if vol_el:
- if active and not self._global_muted:
- vol = self._source_volumes.get(source_name, 1.0)
- else:
- vol = 0.0
- vol_el.set_property("volume", vol)
- log.info("Recording audio %s: %s (vol=%.2f)", "unmuted" if active else "muted", source_name, vol)
-
- def set_muted(self, muted: bool) -> None:
- """Global mute/unmute all audio sources in the recording pipeline."""
- self._global_muted = muted
- for dev, vol_el in self._audio_vol_elements.items():
- if muted:
- vol_el.set_property("volume", 0.0)
+ self._frames.put_nowait(item)
+ except queue.Full:
+ try:
+ self._frames.get_nowait()
+ self.dropped_frames += 1
+ except queue.Empty:
+ pass
+ try:
+ self._frames.put_nowait(item)
+ except queue.Full:
+ self.dropped_frames += 1
+
+ def write_audio(self, name: str, pcm: bytes) -> None:
+ with self._lock:
+ source = self._pcm_sources.get(name)
+ if source is not None and not self._stop_event.is_set():
+ source.emit("push-buffer", Gst.Buffer.new_wrapped(pcm))
+
+ def _capture_playback(self, name, pid):
+ from core.audio_monitor import AudioMonitor
+ index = AudioMonitor._find_sink_input_by_pid(pid)
+ if index is None:
+ raise RuntimeError(f"No playback audio is available for {name}")
+ inputs = json.loads(subprocess.check_output(
+ ["pactl", "-f", "json", "list", "sink-inputs"], text=True, timeout=5))
+ sink = next(item["sink"] for item in inputs if item["index"] == index)
+ sinks = json.loads(subprocess.check_output(
+ ["pactl", "-f", "json", "list", "sinks"], text=True, timeout=5))
+ monitor = next(item["monitor_source"] for item in sinks if item["index"] == sink)
+ # PulseAudio filters this monitor to one sink-input. Never record the
+ # whole desktop monitor or substitute the user's default microphone.
+ process = subprocess.Popen(
+ ["parec", f"--monitor-stream={index}", f"--device={monitor}", "--raw",
+ "--format=s16le", "--rate=48000", "--channels=2", "--latency-msec=40"],
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
+ def read():
+ while pcm := process.stdout.read(3840):
+ self.write_audio(name, pcm)
+ thread = threading.Thread(target=read, name="bigcam-record-audio", daemon=True)
+ self._audio_processes.append((process, thread))
+ thread.start()
+
+ def stop(self):
+ """Request asynchronous EOS; the return value is NOT a saved-file result."""
+ if not self._done.is_set():
+ self._set_state("finalizing")
+ self._stop_event.set()
+ return None
+
+ def wait_finalize(self, timeout=20.0):
+ """Emergency shutdown/test hook. Do not call on the interactive GTK loop."""
+ return self._done.wait(timeout)
+
+ def set_source_active(self, source_name, active):
+ with self._lock:
+ if active:
+ self._active.add(source_name)
else:
- if dev == "__mic__":
- vol_el.set_property("volume", 1.0)
- # Restore per-source volume if source is active
- elif dev in self._active_audio_set:
- vol = self._source_volumes.get(dev, 1.0)
- vol_el.set_property("volume", vol)
- # If not active, keep at 0.0
- log.info("Recording global mute: %s", muted)
-
- def set_source_volume(self, source_name: str, volume: float) -> None:
- """Update per-source volume in the recording pipeline."""
- volume = max(0.0, min(1.0, volume))
- self._source_volumes[source_name] = volume
- vol_el = self._audio_vol_elements.get(source_name)
- if vol_el:
- # Only apply if source is active and not globally muted
- if source_name in self._active_audio_set and not self._global_muted:
- vol_el.set_property("volume", volume)
- log.info("Recording source volume: %s = %.2f", source_name, volume)
-
- def write_frame(self, bgr: Any) -> None:
- """Push a processed BGR frame into the recording pipeline."""
- if not self._recording:
- return
+ self._active.discard(source_name)
- h, w = bgr.shape[:2]
- if not self._ensure_pipeline(w, h):
+ def set_source_volume(self, source_name, volume):
+ value = float(volume)
+ if not math.isfinite(value):
return
-
- # Resize if camera changed resolution (e.g. camera switch while recording)
- if (w != self._w or h != self._h) and _HAS_CV2:
- bgr = cv2.resize(bgr, (self._w, self._h), interpolation=cv2.INTER_LINEAR)
-
- data = bgr.tobytes()
- buf = Gst.Buffer.new_wrapped(data)
- # We let appsrc (do-timestamp=true) handle the timestamps relative to pipeline start
- if self._vsrc:
- ret = self._vsrc.emit("push-buffer", buf)
- if ret != Gst.FlowReturn.OK:
- log.warning("Recording appsrc push error: %s", ret)
-
- def _on_error(self, _bus, msg):
- err, dbg = msg.parse_error()
- log.error("Recording pipeline error: %s (%s)", err.message, dbg)
-
- def stop(self) -> str | None:
- """Stop recording and finalize the file."""
- if not self._recording:
- return None
-
- self._recording = False
+ with self._lock:
+ self._volumes[source_name] = max(0.0, min(1.0, value))
+
+ def set_muted(self, muted):
+ with self._lock:
+ self._muted = bool(muted)
+
+ def _volume(self, device):
+ with self._lock:
+ if self._muted or device not in self._active:
+ return 0.0
+ if self._external_audio.get(device):
+ return 1.0 # Playback gain has already been applied by PulseAudio.
+ value = float(self._volumes.get(device, 1.0))
+ return max(0.0, min(1.0, value)) if math.isfinite(value) else 0.0
+
+ def _select_encoder(self, w, h):
+ config = self._session_config
+ for name, encoder in config.encoders():
+ if self._stop_event.is_set():
+ raise RuntimeError("Recording cancelled before the encoder was ready")
+ if not Gst.ElementFactory.find(name):
+ continue
+ probe = None
+ try:
+ probe = Gst.parse_launch(
+ f"videotestsrc num-buffers=2 ! video/x-raw,width={w},height={h},framerate=30/1 ! "
+ f"videoconvert ! {encoder} ! fakesink sync=false")
+ if probe.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
+ continue
+ message = probe.get_bus().timed_pop_filtered(
+ 3 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR)
+ if message and message.type == Gst.MessageType.EOS:
+ log.info("Recording encoder verified: %s", name)
+ return encoder
+ log.warning("Encoder %s did not complete a test encode; trying fallback", name)
+ except GLib.Error:
+ log.warning("Encoder %s could not be configured", name)
+ finally:
+ if probe is not None:
+ probe.set_state(Gst.State.NULL)
+ raise RuntimeError("No usable video encoder is installed")
+
+ def _audio_encoder(self):
+ candidates = {"aac": ["avenc_aac", "fdkaacenc", "voaacenc"],
+ "mp3": ["lamemp3enc"], "vorbis": ["vorbisenc"], "opus": ["opusenc"]}
+ for name in candidates[self._session_config.audio_codec]:
+ if Gst.ElementFactory.find(name):
+ return name
+ raise RuntimeError("The selected audio encoder is not installed")
+
+ def _build(self, w, h):
+ encoder = self._select_encoder(w, h)
+ rate = self._fps
+ desc = (f"appsrc name=vsrc format=time is-live=true block=false max-buffers=4 "
+ f"leaky-type=downstream caps=video/x-raw,format=BGR,width={w},height={h},framerate={rate} ! "
+ f"queue max-size-buffers=4 max-size-bytes=0 max-size-time=0 ! videoconvert ! {encoder} ! "
+ f"{self._session_config.muxer} name=mux ! filesink location={gst_quote(self._output_path)} ")
+ if self._audio_devices:
+ desc += (f"audiomixer name=amix ignore-inactive-pads=true latency=100000000 ! audioconvert ! "
+ f"audioresample ! {self._audio_encoder()} ! queue ! mux. "
+ "audiotestsrc name=asilence is-live=true wave=silence ! "
+ "audio/x-raw,rate=48000,channels=2 ! amix. ")
+ for i, device in enumerate(self._audio_devices):
+ if device in self._external_audio:
+ rate, channels = (48000, 2) if self._external_audio[device] else (16000, 1)
+ desc += (f"appsrc name=asrc_{i} format=time is-live=true do-timestamp=true "
+ f"block=false max-buffers=8 leaky-type=downstream "
+ f"caps=audio/x-raw,format=S16LE,rate={rate},channels={channels},layout=interleaved ! ")
+ else:
+ desc += (f"pulsesrc device={gst_quote(device)} name=asrc_{i} "
+ f"do-timestamp=true provide-clock=false buffer-time=200000 latency-time=50000 ! ")
+ desc += (
+ f"queue max-size-time=500000000 max-size-buffers=0 max-size-bytes=0 leaky=downstream ! "
+ f"audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 ! volume name=avol_{i} volume={self._volume(device)} ! amix. ")
+ pipeline = Gst.parse_launch(desc)
+ if pipeline.set_state(Gst.State.PLAYING) == Gst.StateChangeReturn.FAILURE:
+ pipeline.set_state(Gst.State.NULL)
+ raise RuntimeError("Failed to start the recording pipeline")
+ return pipeline
+
+ @staticmethod
+ def _check_bus(bus):
+ message = bus.pop_filtered(Gst.MessageType.ERROR)
+ if message:
+ err, _debug = message.parse_error()
+ raise RuntimeError(err.message)
+
+ def _record(self):
+ pipeline = None
+ error = ""
+ success = False
path = self._output_path
-
- if self._pipeline:
- # Capture references before clearing — finalization runs in background
- pipeline = self._pipeline
- vsrc = self._vsrc
- audio_srcs = list(self._audio_srcs)
- self._pipeline = None
- self._vsrc = None
- self._audio_srcs = []
-
- def _finalize():
- # Stop audio sources immediately and inject EOS downstream
- for asrc in audio_srcs:
- src_pad = asrc.get_static_pad("src")
- peer = src_pad.get_peer() if src_pad else None
- asrc.set_state(Gst.State.NULL)
- if peer:
- peer.send_event(Gst.Event.new_eos())
- # Stop video source
- if vsrc:
- vsrc.emit("end-of-stream")
-
- # Wait for EOS to propagate through mux → filesink
- bus = pipeline.get_bus()
- msg = bus.timed_pop_filtered(
- 10 * Gst.SECOND,
- Gst.MessageType.EOS | Gst.MessageType.ERROR,
- )
- if msg and msg.type == Gst.MessageType.EOS:
- log.info("Recording pipeline EOS received")
- elif msg and msg.type == Gst.MessageType.ERROR:
- err, dbg = msg.parse_error()
- log.error("Recording stop error: %s (%s)", err.message, dbg)
+ try:
+ deadline = time.monotonic() + 15
+ first = None
+ while first is None:
+ if self._stop_event.is_set() or time.monotonic() >= deadline:
+ raise RuntimeError("No video frame received before recording stopped")
+ try:
+ first = self._frames.get(timeout=0.1)
+ except queue.Empty:
+ pass
+ _, image = first
+ h, w = image.shape[:2]
+ w, h = max(2, w // 2 * 2), max(2, h // 2 * 2)
+ pipeline = self._build(w, h)
+ with self._lock:
+ self._pcm_sources = {device: pipeline.get_by_name(f"asrc_{i}")
+ for i, device in enumerate(self._audio_devices)
+ if device in self._external_audio}
+ for device, pid in self._external_audio.items():
+ if pid and device in self._audio_devices:
+ self._capture_playback(device, pid)
+ bus = pipeline.get_bus()
+ vsrc = pipeline.get_by_name("vsrc")
+ volumes = [(device, pipeline.get_by_name(f"avol_{i}"))
+ for i, device in enumerate(self._audio_devices)]
+ origin = time.monotonic_ns()
+ last_pts = -1
+ self._set_state("recording")
+ # Encoding probes can take seconds. Do not squeeze queued startup
+ # frames into identical mux timestamps at the start of the movie.
+ pending = (origin, first[1])
+ while not self._frames.empty():
+ try:
+ self._frames.get_nowait()
+ self.dropped_frames += 1
+ except queue.Empty:
+ break
+ last_frame_at = time.monotonic()
+ while not self._stop_event.is_set() or not self._frames.empty() or pending is not None:
+ self._check_bus(bus)
+ if any(process.poll() is not None for process, _thread in self._audio_processes):
+ raise RuntimeError("External audio capture stopped unexpectedly")
+ for device, volume in volumes:
+ volume.set_property("volume", self._volume(device))
+ if pending is None:
+ try:
+ pending = self._frames.get(timeout=0.1)
+ except queue.Empty:
+ if time.monotonic() - last_frame_at > 10:
+ raise RuntimeError("The camera stopped supplying frames")
+ continue
+ captured, frame = pending
+ pending = None
+ last_frame_at = time.monotonic()
+ if frame.shape[:2] != (h, w):
+ frame = cv2.resize(frame, (w, h), interpolation=cv2.INTER_AREA)
+ buffer = bgr_buffer(frame)
+ buffer.pts = max(last_pts + 1, captured - origin, 0)
+ buffer.dts = Gst.CLOCK_TIME_NONE
+ last_pts = buffer.pts
+ if vsrc.emit("push-buffer", buffer) != Gst.FlowReturn.OK:
+ raise RuntimeError("Video encoder rejected a frame")
+ self.frames_written += 1
+ self._set_state("finalizing")
+ vsrc.emit("end-of-stream")
+ # EOS each source downstream without a competing bus signal watch.
+ for i in range(len(self._audio_devices)):
+ source = pipeline.get_by_name(f"asrc_{i}")
+ if self._audio_devices[i] in self._external_audio:
+ source.emit("end-of-stream")
else:
- log.warning("Recording stop: EOS timeout after 10s")
-
+ source.send_event(Gst.Event.new_eos())
+ if self._audio_devices:
+ pipeline.get_by_name("asilence").send_event(Gst.Event.new_eos())
+ message = bus.timed_pop_filtered(10 * Gst.SECOND, Gst.MessageType.EOS | Gst.MessageType.ERROR)
+ if message is None:
+ raise RuntimeError("Recording finalization timed out; the partial file was preserved")
+ if message.type == Gst.MessageType.ERROR:
+ err, _debug = message.parse_error()
+ raise RuntimeError(err.message)
+ pipeline.set_state(Gst.State.NULL)
+ if not self.frames_written or os.path.getsize(path) == 0:
+ raise RuntimeError("The encoder produced no media")
+ with open(path, "rb") as media:
+ os.fsync(media.fileno())
+ success = True
+ except Exception as exc:
+ error = str(exc)
+ log.exception("Recording failed")
+ finally:
+ with self._lock:
+ self._pcm_sources.clear()
+ for process, thread in self._audio_processes:
+ if process.poll() is None:
+ process.terminate()
+ try:
+ process.wait(timeout=2)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=2)
+ thread.join(timeout=2)
+ process.stdout.close()
+ if pipeline is not None:
pipeline.set_state(Gst.State.NULL)
- self._remux_container(path)
-
- self._finalize_thread = threading.Thread(
- target=_finalize, daemon=True, name="rec-finalize",
- )
- self._finalize_thread.start()
-
- log.info("Recording stopped: %s", path)
- return path
+ self._set_state("idle" if success else "error")
+ self._done.set()
+ GLib.idle_add(self._finalized, path, success, error)
- def wait_finalize(self, timeout: float = 20.0) -> None:
- """Block until the finalize thread completes (call before app exit)."""
- t = self._finalize_thread
- if t is not None and t.is_alive():
- t.join(timeout=timeout)
-
- def _remux_container(self, path: str) -> None:
- """Remux container to fix metadata (duration, seek cues)."""
- if not os.path.isfile(path):
- return
- ext = self._container_ext()
- tmp = path + f".remux{ext}"
- try:
- result = subprocess.run(
- ["ffmpeg", "-y", "-i", path, "-c", "copy", tmp],
- capture_output=True,
- timeout=120,
- )
- if result.returncode == 0 and os.path.isfile(tmp) and os.path.getsize(tmp) > 0:
- os.replace(tmp, path)
- log.info("Container metadata fixed: %s", os.path.basename(path))
- else:
- if os.path.isfile(tmp):
- os.remove(tmp)
- log.warning("Remux failed: %s", result.stderr.decode(errors="replace")[:300])
- except FileNotFoundError:
- log.debug("ffmpeg not available for container remux")
- except subprocess.TimeoutExpired:
- log.warning("Remux timed out for %s", os.path.basename(path))
- if os.path.isfile(tmp):
- os.remove(tmp)
-
- def _stop_pipeline(self) -> None:
- if self._pipeline:
- self._pipeline.set_state(Gst.State.NULL)
- self._pipeline = None
- self._vsrc = None
- self._audio_srcs = []
+ def _finalized(self, path, success, error):
+ self.emit("finalized", path, success, error)
+ return GLib.SOURCE_REMOVE
diff --git a/usr/share/biglinux/bigcam/core/virtual_camera.py b/usr/share/biglinux/bigcam/core/virtual_camera.py
index 8717bf8..a19099d 100644
--- a/usr/share/biglinux/bigcam/core/virtual_camera.py
+++ b/usr/share/biglinux/bigcam/core/virtual_camera.py
@@ -9,6 +9,7 @@
import subprocess
from utils.command_runner import SecureCommandRunner
import threading
+import uuid
log = logging.getLogger(__name__)
@@ -17,52 +18,30 @@
def _run_privileged(action: str) -> bool:
- """Run modprobe via passwordless sudo (sudoers.d/bigcam)."""
- cmd = _modprobe_args(action)
- result = SecureCommandRunner.run_safe(
- ["sudo", "-n", *cmd],
- capture_output=True,
- timeout=15,
- )
- if result.returncode != 0:
- log.error(
- "sudo -n modprobe failed (rc=%d): %s",
- result.returncode,
- result.stderr.strip(),
+ if action != "load":
+ log.warning("Refusing to unload a potentially shared kernel module")
+ return False
+ return _helper("load").returncode == 0
+
+
+def _helper(*args: str) -> subprocess.CompletedProcess:
+ """Run only the installed fixed-operation helper, never a user script."""
+ try:
+ return SecureCommandRunner.run_safe(
+ ["pkexec", "/usr/lib/bigcam/virtual-camera-helper", *args],
+ capture_output=True, text=True, timeout=120,
)
- return result.returncode == 0
-
-
-def _modprobe_args(action: str) -> list[str]:
- """Return the modprobe argument list for the given action."""
- _modprobe = shutil.which("modprobe") or "/usr/bin/modprobe"
- if action == "unload":
- return [_modprobe, "-r", "v4l2loopback"]
- # Load module with no initial devices — devices are created dynamically
- # via v4l2loopback-ctl add. Fall back to fixed devices if ctl unavailable.
- if os.path.isfile(_V4L2LOOPBACK_CTL):
- return [_modprobe, "v4l2loopback", "devices=0"]
- max_devs = VirtualCamera.get_max_devices()
- devices_str = ",".join(str(20 + i) for i in range(max_devs))
- exclusive_caps_str = ",".join(["1"] * max_devs)
- labels_str = ",".join([f"BigCam Virtual {i+1}" for i in range(max_devs)])
- return [
- _modprobe,
- "v4l2loopback",
- f"devices={max_devs}",
- f"exclusive_caps={exclusive_caps_str}",
- "max_buffers=8",
- f"video_nr={devices_str}",
- f"card_label={labels_str}",
- ]
+ except (OSError, subprocess.SubprocessError) as exc:
+ log.error("Virtual camera authorization failed: %s", exc)
+ return subprocess.CompletedProcess(args, 1, "", str(exc))
class VirtualCamera:
"""Manage v4l2loopback virtual camera output.
Supports multiple simultaneous virtual cameras — one per physical camera.
- Devices are created dynamically via v4l2loopback-ctl when available,
- falling back to a fixed pool of 4 devices otherwise.
+ Devices are created by the authorized helper and owned by this session.
+ Existing devices belonging to other applications are never taken over.
"""
_loopback_device: str = ""
@@ -81,6 +60,7 @@ class VirtualCamera:
_next_vcam_number: int = 1
_labels_synced: bool = False
_alloc_lock = threading.RLock()
+ _session_id = uuid.uuid4().hex
@staticmethod
def is_available() -> bool:
@@ -95,10 +75,7 @@ def kernel_status() -> str:
@classmethod
def _is_dynamic_supported(cls) -> bool:
- """Check if v4l2loopback-ctl is available for dynamic device management."""
- if cls._dynamic_supported is None:
- cls._dynamic_supported = os.path.isfile(_V4L2LOOPBACK_CTL)
- return cls._dynamic_supported
+ return os.path.isfile(_V4L2LOOPBACK_CTL) and os.path.isfile("/usr/lib/bigcam/virtual-camera-helper")
@staticmethod
def find_all_loopback_devices() -> list[str]:
@@ -181,130 +158,58 @@ def find_loopback_device() -> str:
@classmethod
def find_free_loopback_device(cls) -> str:
- """Return a v4l2loopback device not currently allocated to any camera."""
+ """Reuse only idle devices created by this process; labels are not ownership."""
with cls._alloc_lock:
allocated = set(cls._allocations.values())
- devices = cls.find_all_loopback_devices()
- if not cls._is_dynamic_supported():
- # If no v4l2loopback-ctl, search in the allocated pool range
- for i in range(cls._max_devices):
- dev = f"/dev/video{20 + i}"
- if dev not in allocated and os.path.exists(dev):
- return dev
- for dev in devices:
- if dev not in allocated:
- return dev
- return ""
+ return next((dev for dev in sorted(cls._dynamic_devices)
+ if dev not in allocated and os.path.exists(dev)), "")
@classmethod
def _add_dynamic_device(cls, label: str) -> str:
- """Dynamically create a v4l2loopback device via v4l2loopback-ctl.
-
- Devices start at /dev/video20 to avoid conflicts with physical cameras.
- """
- # Find the next available high device number (20+)
- dev_num = 20
- with cls._alloc_lock:
- used_nums = set()
- for dev in list(cls._allocations.values()) + list(cls._dynamic_devices):
- try:
- used_nums.add(int(dev.replace("/dev/video", "")))
- except (ValueError, AttributeError):
- pass
- while dev_num in used_nums or os.path.exists(f"/dev/video{dev_num}"):
- dev_num += 1
- try:
- result = SecureCommandRunner.run_safe(
- ["sudo", "-n", _V4L2LOOPBACK_CTL, "add",
- "-n", label, "-x", "1", "-b", "8",
- f"/dev/video{dev_num}"],
- capture_output=True,
- text=True,
- timeout=15,
- )
- if result.returncode == 0:
- dev = result.stdout.strip()
- if dev.startswith("/dev/video"):
- with cls._alloc_lock:
- cls._dynamic_devices.add(dev)
- log.info("Dynamically created v4l2loopback: %s (%s)", dev, label)
- return dev
- log.warning(
- "v4l2loopback-ctl add failed (rc=%d): %s",
- result.returncode,
- result.stderr.strip(),
- )
- except Exception:
- log.error("Failed to run v4l2loopback-ctl add", exc_info=True)
+ safe_label = "".join(c if c.isalnum() or c in " ._-" else "_" for c in label)
+ safe_label = safe_label.encode("utf-8")[:31].decode("utf-8", "ignore") or "BigCam"
+ result = _helper("create", cls._session_id, safe_label)
+ device = result.stdout.strip()
+ if result.returncode == 0 and re.fullmatch(r"/dev/video[0-9]{1,3}", device):
+ with cls._alloc_lock:
+ cls._dynamic_devices.add(device)
+ return device
+ log.warning("Could not create an owned virtual camera: %s", result.stderr.strip())
return ""
@classmethod
def _delete_dynamic_device(cls, dev: str) -> bool:
- """Delete a dynamically created v4l2loopback device."""
- try:
- result = SecureCommandRunner.run_safe(
- ["sudo", "-n", _V4L2LOOPBACK_CTL, "delete", dev],
- capture_output=True,
- text=True,
- timeout=15,
- )
+ with cls._alloc_lock:
+ if dev not in cls._dynamic_devices or dev in cls._allocations.values():
+ return False
+ result = _helper("delete", cls._session_id, dev)
if result.returncode == 0:
- with cls._alloc_lock:
- cls._dynamic_devices.discard(dev)
- log.info("Deleted v4l2loopback device: %s", dev)
+ cls._dynamic_devices.discard(dev)
return True
- log.warning("v4l2loopback-ctl delete failed for %s: %s",
- dev, result.stderr.strip())
- except Exception:
- log.error("Failed to delete v4l2loopback device %s", dev, exc_info=True)
+ log.warning("Keeping virtual camera after unsuccessful cleanup: %s", dev)
return False
@classmethod
def allocate_device(cls, camera_id: str) -> str:
- """Allocate a v4l2loopback device for a camera. Returns device path."""
+ """Allocate a device created by this session, never an unregistered loopback."""
with cls._alloc_lock:
- # Already allocated?
if camera_id in cls._allocations:
return cls._allocations[camera_id]
- # Check max devices limit
if len(cls._allocations) >= cls._max_devices:
- log.warning("Max virtual cameras (%d) reached, cannot allocate for %s",
- cls._max_devices, camera_id)
+ from gi.repository import GLib
from core.event_bus import event_bus
- event_bus.emit("vcam-limit-reached", cls._max_devices)
+ GLib.idle_add(event_bus.emit, "vcam-limit-reached", cls._max_devices)
return ""
- device = ""
- if cls._is_dynamic_supported():
- # Prefer a free device whose label matches the template
- dev_labels = cls._get_device_labels()
- tpl_pat = re.compile(
- re.escape(cls._name_template) + r"\s+\d+$"
- )
- allocated = set(cls._allocations.values())
- for dev in cls.find_all_loopback_devices():
- if dev not in allocated:
- lbl = dev_labels.get(dev, "")
- if lbl and tpl_pat.match(lbl):
- device = dev
- break
- if not device:
- # No matching free device — create a dynamic one
- existing = cls._get_existing_labels()
- n = 1
- while f"{cls._name_template} {n}" in existing:
- n += 1
- label = f"{cls._name_template} {n}"
- device = cls._add_dynamic_device(label)
- else:
- # Fallback: use any free loopback device (no dynamic support)
- device = cls.find_free_loopback_device()
+ if not cls._is_dynamic_supported():
+ log.warning("Install the BigCam Polkit helper and v4l2loopback-ctl to create virtual cameras")
+ return ""
+ device = cls.find_free_loopback_device()
+ if not device:
+ device = cls._add_dynamic_device(f"{cls._name_template} {cls._next_vcam_number}")
+ if device:
+ cls._next_vcam_number += 1
if device:
cls._allocations[camera_id] = device
- log.info("Allocated %s for camera %s", device, camera_id)
- else:
- from core.event_bus import event_bus
- actual_limit = min(len(cls._allocations), cls._max_devices)
- event_bus.emit("vcam-limit-reached", actual_limit)
return device
@classmethod
@@ -323,30 +228,11 @@ def get_device_for_camera(cls, camera_id: str) -> str:
@classmethod
def cleanup_dynamic_devices(cls) -> None:
- """Delete all dynamically created v4l2loopback devices.
-
- Also removes stale devices from previous sessions that are no
- longer tracked by the app (prevents device accumulation).
- """
- with cls._alloc_lock:
- tracked = list(cls._dynamic_devices)
- cls._allocations.clear()
- deleted = 0
- for dev in tracked:
- if cls._delete_dynamic_device(dev):
- deleted += 1
- # Clean up stale v4l2loopback devices not tracked in this session
- if cls._is_dynamic_supported():
- all_loopback = cls.find_all_loopback_devices()
- stale = [d for d in all_loopback if d not in tracked]
- for dev in stale:
- if cls._delete_dynamic_device(dev):
- deleted += 1
+ """Delete only released devices created by this session; retain failures."""
with cls._alloc_lock:
- cls._dynamic_devices.clear()
- cls._next_vcam_number = 1
- cls._labels_synced = False
- log.info("Cleaned up %d v4l2loopback devices", deleted)
+ idle = cls._dynamic_devices - set(cls._allocations.values())
+ for device in sorted(idle):
+ cls._delete_dynamic_device(device)
@classmethod
def reset_all_allocations(cls) -> None:
@@ -361,21 +247,16 @@ def reset_all_allocations(cls) -> None:
def load_module(cls, card_label: str | None = None) -> bool:
"""Load v4l2loopback kernel module.
- When v4l2loopback-ctl is available, loads with devices=0 and
- creates devices dynamically. Otherwise falls back to 4 fixed devices.
+ The helper uses fixed options and never unloads a shared module.
"""
return _run_privileged("load")
@classmethod
def start(cls, gst_pipeline: str) -> bool:
"""Start writing to the loopback device."""
- device = cls.find_loopback_device()
+ device = cls.ensure_ready(camera_id="legacy-pipeline")
if not device:
- if not cls.load_module():
- return False
- device = cls.find_loopback_device()
- if not device:
- return False
+ return False
cls._loopback_device = device
try:
@@ -471,14 +352,12 @@ def ensure_ready(cls, card_label: str | None = None, camera_id: str = "") -> str
# Module is loaded — allocate a device (creates dynamically if needed)
if camera_id:
return cls.allocate_device(camera_id)
- device = cls.find_loopback_device()
- return device
+ return cls.allocate_device("default")
@staticmethod
def _reload_module() -> bool:
- """Unload and reload v4l2loopback with correct parameters."""
- _run_privileged("unload")
- return _run_privileged("load")
+ log.warning("Reload requires explicit administrator action; shared devices are not interrupted")
+ return False
def _is_module_loaded() -> bool:
diff --git a/usr/share/biglinux/bigcam/main.py b/usr/share/biglinux/bigcam/main.py
index 1c9d24a..9abe50a 100644
--- a/usr/share/biglinux/bigcam/main.py
+++ b/usr/share/biglinux/bigcam/main.py
@@ -90,6 +90,14 @@ def _show_welcome(self, win: Gtk.Window) -> bool:
self._welcome_dialog.present()
return False
+ def _request_quit(self):
+ windows = self.get_windows()
+ if not windows:
+ self.quit()
+ return
+ for window in windows:
+ window.close()
+
def do_startup(self) -> None:
Adw.Application.do_startup(self)
@@ -134,7 +142,7 @@ def do_startup(self) -> None:
# Quit action
quit_action = Gio.SimpleAction.new("quit", None)
- quit_action.connect("activate", lambda *_: self.quit())
+ quit_action.connect("activate", lambda *_: self._request_quit())
self.add_action(quit_action)
self.set_accels_for_action("app.quit", ["q"])
diff --git a/usr/share/biglinux/bigcam/script/install-archlinux.sh b/usr/share/biglinux/bigcam/script/install-archlinux.sh
index be04942..f1582f7 100755
--- a/usr/share/biglinux/bigcam/script/install-archlinux.sh
+++ b/usr/share/biglinux/bigcam/script/install-archlinux.sh
@@ -42,7 +42,6 @@ PACMAN_PACKAGES=(
"python-gobject" # Python GObject bindings (PyGObject)
"gtk4" # GTK4 toolkit
"libadwaita" # Adwaita library for modern GNOME apps
- "linux-headers" # Kernel headers for DKMS module compilation
)
OPTIONAL_PACKAGES=(
"v4l-utils" # Video4Linux utilities (v4l2-ctl)
@@ -131,7 +130,8 @@ else
TMPFILE=$(mktemp /tmp/90-libgphoto2.rules.XXXXXX)
sudo /usr/lib/libgphoto2/print-camera-list udev-rules version 201 > "$TMPFILE" 2>/dev/null || true
if [ -s "$TMPFILE" ]; then
- sudo mv "$TMPFILE" "$UDEV_RULE"
+ sudo install -o root -g root -m 0644 -- "$TMPFILE" "$UDEV_RULE"
+ rm -f -- "$TMPFILE"
sudo udevadm control --reload-rules
echo -e " ${GREEN}✓${NC} Regra udev criada"
else
diff --git a/usr/share/biglinux/bigcam/script/load-v4l2loopback.sh b/usr/share/biglinux/bigcam/script/load-v4l2loopback.sh
index f14bfa8..09a192e 100755
--- a/usr/share/biglinux/bigcam/script/load-v4l2loopback.sh
+++ b/usr/share/biglinux/bigcam/script/load-v4l2loopback.sh
@@ -1,33 +1,8 @@
-#!/bin/bash
-# Load the v4l2loopback kernel module with BigCam parameters.
-# Called via pkexec from virtual_camera.py.
+#!/usr/bin/env bash
+# Compatibility entry point. Never accepts raw modprobe arguments.
set -euo pipefail
-
-ACTION="${1:-load}"
-
-case "$ACTION" in
- load)
- modprobe v4l2loopback \
- devices=4 \
- exclusive_caps=1,1,1,1 \
- max_buffers=4 \
- video_nr=10,11,12,13 \
- "card_label=BigCam Virtual 1,BigCam Virtual 2,BigCam Virtual 3,BigCam Virtual 4"
- ;;
- unload)
- modprobe -r v4l2loopback
- ;;
- reload)
- modprobe -r v4l2loopback 2>/dev/null || true
- modprobe v4l2loopback \
- devices=4 \
- exclusive_caps=1,1,1,1 \
- max_buffers=4 \
- video_nr=10,11,12,13 \
- "card_label=BigCam Virtual 1,BigCam Virtual 2,BigCam Virtual 3,BigCam Virtual 4"
- ;;
- *)
- echo "Usage: $0 {load|unload|reload}" >&2
- exit 1
- ;;
-esac
+if [[ $# -gt 1 || ${1:-load} != load ]]; then
+ echo 'Only load is supported. Shared kernel modules are never unloaded by BigCam.' >&2
+ exit 2
+fi
+exec /usr/bin/pkexec /usr/lib/bigcam/virtual-camera-helper load
diff --git a/usr/share/biglinux/bigcam/script/migrate-legacy-policy.py b/usr/share/biglinux/bigcam/script/migrate-legacy-policy.py
new file mode 100644
index 0000000..48468d0
--- /dev/null
+++ b/usr/share/biglinux/bigcam/script/migrate-legacy-policy.py
@@ -0,0 +1,56 @@
+#!/usr/bin/python3 -I
+"""Remove the exact legacy BigCam sudo rules, preserving other administrator rules."""
+from pathlib import Path
+import os
+import shutil
+import subprocess
+import tempfile
+
+LEGACY = {
+ f"%wheel ALL=(root) NOPASSWD: /usr/{directory}/{command}"
+ for directory in ("bin", "sbin")
+ for command in ("modprobe v4l2loopback *", "modprobe -r v4l2loopback",
+ "v4l2loopback-ctl add *", "v4l2loopback-ctl delete *")
+}
+
+
+def filtered(text: str) -> str:
+ return "".join(line for line in text.splitlines(keepends=True) if line.strip() not in LEGACY)
+
+
+def main() -> int:
+ if os.geteuid() != 0:
+ raise SystemExit("Migration requires root")
+ path = Path("/etc/sudoers.d/bigcam")
+ if path.is_symlink():
+ raise SystemExit("Refusing symlink sudoers policy; administrator intervention required")
+ if not path.exists():
+ return 0
+ old = path.read_text()
+ new = filtered(old)
+ if new == old:
+ return 0
+ backup_dir = Path("/var/lib/bigcam/policy-backups")
+ backup_dir.mkdir(parents=True, mode=0o700, exist_ok=True)
+ backup_fd, backup = tempfile.mkstemp(prefix="sudoers-", dir=backup_dir)
+ with os.fdopen(backup_fd, "w") as stream:
+ stream.write(old)
+ fd, temporary = tempfile.mkstemp(prefix=".bigcam-", dir=path.parent)
+ try:
+ with os.fdopen(fd, "w") as stream:
+ stream.write(new or "# Legacy BigCam passwordless rules removed.\n")
+ os.chmod(temporary, 0o440)
+ visudo = shutil.which("visudo", path="/usr/sbin:/usr/bin:/sbin:/bin")
+ if visudo is None:
+ raise RuntimeError("visudo is required to validate the migrated policy")
+ subprocess.run([visudo, "-cf", temporary], check=True, timeout=10)
+ os.replace(temporary, path)
+ print(f"Removed unsafe legacy BigCam rules. Private backup: {backup}")
+ finally:
+ if os.path.exists(temporary):
+ os.unlink(temporary)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/usr/share/biglinux/bigcam/script/run_webcam.sh b/usr/share/biglinux/bigcam/script/run_webcam.sh
index 53e9098..558762f 100755
--- a/usr/share/biglinux/bigcam/script/run_webcam.sh
+++ b/usr/share/biglinux/bigcam/script/run_webcam.sh
@@ -1,110 +1,4 @@
-#!/bin/bash
-set -uo pipefail
-exec 2>&1
-
-USB_PORT="${1:-}"
-UDP_PORT="${2:-5000}"
-CAM_NAME="${3:-Canon DSLR}"
-# Remove commas to prevent modprobe array parsing errors
-CAM_NAME="${CAM_NAME//,/}"
-CARD_LABELS="BigCam Virtual 1,BigCam Virtual 2,BigCam Virtual 3,BigCam Virtual 4"
-
-
-if [ -n "$USB_PORT" ]; then
- PORT_STR="--port $USB_PORT"
- # Kill only THIS camera's previous instances
- pkill -f "gphoto2.*--port $USB_PORT" 2>/dev/null
- pkill -f "ffmpeg.*udp://127.0.0.1:$UDP_PORT" 2>/dev/null
- sleep 1
-else
- PORT_STR=""
-fi
-
-# Kill gvfs interference more effectively
-systemctl --user stop gvfs-gphoto2-volume-monitor.service 2>/dev/null
-pkill -9 -f "gvfs-gphoto2-volume-monitor" 2>/dev/null
-gio mount -u gphoto2://* 2>/dev/null
-sleep 2
-
-# Reset the USB interface of the camera before starting
-# if [ -n "$USB_PORT" ]; then
-# timeout 8 gphoto2 --port "$USB_PORT" --reset >/dev/null 2>&1
-# else
-# timeout 8 gphoto2 --reset >/dev/null 2>&1
-# fi
-# sleep 2
-
-# Load v4l2loopback with 4 virtual devices if not loaded
-if ! lsmod | grep -q v4l2loopback; then
- sudo -n modprobe v4l2loopback devices=4 exclusive_caps=1,1,1,1 max_buffers=4 \
- video_nr=10,11,12,13 "card_label=$CARD_LABELS"
- sleep 1
-else
- # If loaded with exclusive_caps=0, reload only if no device is in use
- if [ "$(cat /sys/module/v4l2loopback/parameters/exclusive_caps 2>/dev/null)" = "0" ]; then
- if ! fuser /dev/video* >/dev/null 2>&1; then
- sudo -n modprobe -r v4l2loopback 2>/dev/null
- sleep 1
- sudo -n modprobe v4l2loopback devices=4 exclusive_caps=1,1,1,1 max_buffers=4 \
- video_nr=10,11,12,13 "card_label=$CARD_LABELS"
- sleep 1
- fi
- fi
-fi
-
-# Find a free v4l2loopback virtual device
-DEVICE_VIDEO=""
-for dev in /dev/video*; do
- [ -e "$dev" ] || continue
- # Check if it's a v4l2loopback device via driver name
- DRIVER=$(v4l2-ctl -d "$dev" --info 2>/dev/null | grep "Driver name" | sed 's/.*: //')
- if echo "$DRIVER" | grep -qi "v4l2.*loopback\|loopback"; then
- # Check if NOT in use by another ffmpeg
- if ! fuser "$dev" >/dev/null 2>&1; then
- DEVICE_VIDEO="$dev"
- break
- fi
- fi
-done
-
-[ -z "$DEVICE_VIDEO" ] && echo "ERROR: No free virtual video device found." && exit 1
-
-# Verify camera is connected with a timeout to prevent hang
-if [ -n "$USB_PORT" ]; then
- if ! timeout 10 gphoto2 --auto-detect 2>&1 | grep -q "$USB_PORT"; then
- echo "ERROR: Camera at $USB_PORT not found or device busy."
- exit 1
- fi
-else
- if ! timeout 10 gphoto2 --auto-detect 2>&1 | grep -q "usb:"; then
- echo "ERROR: No camera detected."
- exit 1
- fi
-fi
-
-# Launch with high quality settings
-LOG="/tmp/canon_webcam_stream_${UDP_PORT}.log"
-ERR_LOG="/tmp/gphoto_err_${UDP_PORT}.log"
-> "$LOG"
-> "$ERR_LOG"
-
-# Quality Upgrades:
-# - Bitrate was 800k (pixilated), now 5000k (sharp)
-# - Removed downscaling (Full native T3 resolution)
-# - Syncing to 30 FPS (Match T3 native output for stability)
-nohup bash -c "gphoto2 --stdout --capture-movie $PORT_STR 2>\"$ERR_LOG\" | ffmpeg -y -hide_banner -loglevel error -stats -i - -filter_complex \"[0:v]format=yuv420p,split=2[v1][v2]\" -map \"[v1]\" -r 30 -f v4l2 \"$DEVICE_VIDEO\" -map \"[v2]\" -f mpegts -r 30 -codec:v mpeg1video -b:v 5000k -bf 0 \"udp://127.0.0.1:${UDP_PORT}?pkt_size=1316\" >\"$LOG\" 2>&1" &
-PID=$!
-disown
-
-# Wait for it to stabilize
-sleep 3
-
-if kill -0 "$PID" 2>/dev/null; then
- echo "SUCCESS: $DEVICE_VIDEO"
- exit 0
-else
- echo "ERROR: Pipeline failed."
- cat "$LOG"
- cat "$ERR_LOG"
- exit 1
-fi
+#!/usr/bin/env bash
+set -Eeuo pipefail
+HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
+exec bash "$HERE/run_webcam_gphoto2.sh" "$@"
diff --git a/usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh b/usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh
index de4cd13..669af2f 100755
--- a/usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh
+++ b/usr/share/biglinux/bigcam/script/run_webcam_gphoto2.sh
@@ -1,167 +1,8 @@
-#!/bin/bash
-set -uo pipefail
-exec 2>&1
-
-USB_PORT="${1:-}"
-UDP_PORT="${2:-5000}"
-CAM_NAME="${3:-DSLR Camera}"
-CAM_NAME="${CAM_NAME//,/}"
-# When called with V4L2_DEV=none, skip writing to v4l2loopback (BigCam handles via appsrc)
-V4L2_DEV="${4:-auto}"
-
-LOG="/tmp/canon_webcam_stream_${UDP_PORT}.log"
-ERR_LOG="/tmp/gphoto_err_${UDP_PORT}.log"
-> "$LOG"
-> "$ERR_LOG"
-
-# ── Step 1: Kill ONLY this camera's previous processes ──
-if [ -n "$USB_PORT" ]; then
- pkill -f "gphoto2.*--port ${USB_PORT}" 2>/dev/null
- sleep 0.5
- pkill -9 -f "gphoto2.*--port ${USB_PORT}" 2>/dev/null
-fi
-pkill -f "ffmpeg.*udp://127.0.0.1:${UDP_PORT}" 2>/dev/null
-sleep 0.5
-pkill -9 -f "ffmpeg.*udp://127.0.0.1:${UDP_PORT}" 2>/dev/null
-sleep 1
-
-# ── Step 2: Kill GVFS interference ──
-systemctl --user stop gvfs-gphoto2-volume-monitor.service 2>/dev/null
-pkill -9 -f "gvfs-gphoto2-volume-monitor" 2>/dev/null
-pkill -9 -f "gvfsd-gphoto2" 2>/dev/null
-gio mount -u gphoto2://* 2>/dev/null
-sleep 1
-
-# ── Step 3: Load v4l2loopback ──
-CARD_LABELS="BigCam Virtual 1,BigCam Virtual 2,BigCam Virtual 3,BigCam Virtual 4"
-if ! lsmod | grep -q v4l2loopback; then
- sudo -n modprobe v4l2loopback devices=4 exclusive_caps=1 max_buffers=4 \
- video_nr=10,11,12,13 "card_label=$CARD_LABELS"
- sleep 1
-else
- if [ "$(cat /sys/module/v4l2loopback/parameters/exclusive_caps 2>/dev/null)" = "0" ]; then
- if ! fuser /dev/video* >/dev/null 2>&1; then
- sudo -n modprobe -r v4l2loopback 2>/dev/null
- sleep 1
- sudo -n modprobe v4l2loopback devices=4 exclusive_caps=1 max_buffers=4 \
- video_nr=10,11,12,13 "card_label=$CARD_LABELS"
- sleep 1
- fi
- fi
-fi
-
-# ── Step 4: Find a free v4l2loopback virtual device (unless skipped) ──
-DEVICE_VIDEO=""
-if [ "$V4L2_DEV" = "none" ]; then
- # BigCam handles v4l2loopback output via appsrc pipeline — only need UDP
- DEVICE_VIDEO=""
-elif [ "$V4L2_DEV" != "auto" ] && [ -e "$V4L2_DEV" ]; then
- # Specific device pre-allocated by BigCam — use it directly
- DEVICE_VIDEO="$V4L2_DEV"
-else
- for dev in /dev/video*; do
- [ -e "$dev" ] || continue
- DRIVER=$(v4l2-ctl -d "$dev" --info 2>/dev/null | grep "Driver name" | sed 's/.*: //')
- if echo "$DRIVER" | grep -qi "v4l2.*loopback\|loopback"; then
- if ! fuser "$dev" >/dev/null 2>&1; then
- DEVICE_VIDEO="$dev"
- break
- fi
- fi
- done
- [ -z "$DEVICE_VIDEO" ] && echo "ERROR: No free virtual video device found." && exit 1
-fi
-
-# ── Step 5: Validate and refresh camera port ──
-if [ -z "$USB_PORT" ]; then
- echo "ERROR: No USB port specified."
- exit 1
-fi
-
-# Kill GVFS again right before port check (it respawns fast)
-pkill -9 -f "gvfs-gphoto2-volume-monitor" 2>/dev/null
-pkill -9 -f "gvfsd-gphoto2" 2>/dev/null
-sleep 0.5
-
-# Verify the specific camera is accessible, re-detect port if needed
-if ! timeout 10 gphoto2 --auto-detect 2>&1 | grep -q "$USB_PORT"; then
- echo "WARN: Camera not at original port $USB_PORT, re-detecting..."
- # Try to find camera by name at a different port
- NEW_PORT=$(timeout 10 gphoto2 --auto-detect 2>/dev/null | grep -Fi "$CAM_NAME" | grep -o 'usb:[^ ]*' | head -1)
- if [ -n "$NEW_PORT" ]; then
- echo "INFO: Camera '$CAM_NAME' found at new port: $NEW_PORT"
- USB_PORT="$NEW_PORT"
- else
- # Do NOT pick a random camera — that would stream the wrong one
- echo "ERROR: Camera '$CAM_NAME' not detected at any port."
- exit 1
- fi
-fi
-
-# ── Step 6: Launch gphoto2 + ffmpeg with retry ──
-MAX_ATTEMPTS=3
-for attempt in $(seq 1 "$MAX_ATTEMPTS"); do
- > "$ERR_LOG"
- > "$LOG"
-
- if [ "$attempt" -gt 1 ]; then
- echo "Retry attempt $attempt/$MAX_ATTEMPTS..."
- pkill -9 -f "gvfs-gphoto2-volume-monitor" 2>/dev/null
- pkill -9 -f "gvfsd-gphoto2" 2>/dev/null
- sleep 3
- fi
-
- # Build ffmpeg command depending on whether v4l2loopback output is needed
- if [ -n "$DEVICE_VIDEO" ]; then
- # Split to v4l2loopback + UDP
- FFMPEG_CMD="ffmpeg -y -hide_banner -loglevel error -stats -i - \
- -filter_complex \"[0:v]format=yuv420p,split=2[v1][v2]\" \
- -map \"[v1]\" -r 30 -f v4l2 \"$DEVICE_VIDEO\" \
- -map \"[v2]\" -f mpegts -r 30 -codec:v mpeg1video -b:v 5000k -bf 0 \
- \"udp://127.0.0.1:${UDP_PORT}?pkt_size=1316\""
- else
- # UDP only (BigCam handles v4l2loopback via appsrc)
- FFMPEG_CMD="ffmpeg -y -hide_banner -loglevel error -stats -i - \
- -f mpegts -r 30 -codec:v mpeg1video -b:v 5000k -bf 0 \
- \"udp://127.0.0.1:${UDP_PORT}?pkt_size=1316\""
- fi
-
- nohup bash -c "gphoto2 --stdout --capture-movie --port '$USB_PORT' 2>\"$ERR_LOG\" | \
- $FFMPEG_CMD >\"$LOG\" 2>&1" &
- PID=$!
- disown
-
- # Wait and verify streaming actually works
- sleep 6
-
- if kill -0 "$PID" 2>/dev/null; then
- # Check for PTP errors
- if grep -q "PTP Timeout\|PTP Error\|Erro na captura" "$ERR_LOG" 2>/dev/null; then
- kill -9 "$PID" 2>/dev/null
- pkill -f "gphoto2.*--port ${USB_PORT}" 2>/dev/null
- pkill -f "ffmpeg.*udp://127.0.0.1:${UDP_PORT}" 2>/dev/null
- sleep 1
- continue
- fi
-
- # Verify ffmpeg is actually writing frames (check log for frame= stats)
- if [ -s "$LOG" ] || ! grep -q "Erro\|Error" "$ERR_LOG" 2>/dev/null; then
- if [ -n "$DEVICE_VIDEO" ]; then
- echo "SUCCESS: $DEVICE_VIDEO"
- else
- echo "SUCCESS: UDP"
- fi
- exit 0
- fi
- fi
-
- # Process died — retry with USB reset
- pkill -f "gphoto2.*--port ${USB_PORT}" 2>/dev/null
- pkill -f "ffmpeg.*udp://127.0.0.1:${UDP_PORT}" 2>/dev/null
- sleep 1
-done
-
-echo "ERROR: Pipeline failed after $MAX_ATTEMPTS attempts."
-cat "$ERR_LOG"
-cat "$LOG"
-exit 1
+#!/usr/bin/env bash
+# Foreground, owned camera producer. No sudo, global process kills or USB resets.
+set -Eeuo pipefail
+[[ $# -ge 2 && $# -le 4 ]] || { echo 'Usage: run_webcam_gphoto2.sh usb:BUS,DEVICE UDP_PORT [NAME] [none]' >&2; exit 2; }
+[[ $1 =~ ^usb:[0-9]{1,3},[0-9]{1,3}$ && $2 =~ ^[0-9]{4,5}$ ]] || { echo 'Invalid camera/port' >&2; exit 2; }
+[[ ${4:-none} == none ]] || { echo 'Use BigCam to create an authorized virtual camera output.' >&2; exit 2; }
+HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)
+exec /usr/bin/python3 "$HERE/../core/gphoto_session.py" "$1" "$2"
diff --git a/usr/share/biglinux/bigcam/style.css b/usr/share/biglinux/bigcam/style.css
index a040634..b792a87 100644
--- a/usr/share/biglinux/bigcam/style.css
+++ b/usr/share/biglinux/bigcam/style.css
@@ -205,10 +205,8 @@ window.bigcam {
@keyframes rec-pulse {
- 0%,
- 100% {
- opacity: 1.0;
- }
+ 0% { opacity: 1.0; }
+ 100% { opacity: 1.0; }
50% {
opacity: 0.3;
@@ -359,10 +357,8 @@ button.zoom-btn:hover {
@keyframes rec-blink {
- 0%,
- 100% {
- opacity: 1;
- }
+ 0% { opacity: 1; }
+ 100% { opacity: 1; }
50% {
opacity: 0;
diff --git a/usr/share/biglinux/bigcam/ui/camera_controls_page.py b/usr/share/biglinux/bigcam/ui/camera_controls_page.py
index 3e923c1..1b29ddb 100644
--- a/usr/share/biglinux/bigcam/ui/camera_controls_page.py
+++ b/usr/share/biglinux/bigcam/ui/camera_controls_page.py
@@ -17,6 +17,8 @@
from core.camera_manager import CameraManager
from core import camera_profiles
from utils.i18n import _
+from utils.async_worker import run_async
+from utils.latest_commands import LatestCommands
_CATEGORY_LABELS = {
ControlCategory.IMAGE: _("Image"),
@@ -55,13 +57,17 @@ def __init__(self, camera_manager: CameraManager, stream_engine=None) -> None:
self._ctrl_widgets: dict[str, tuple[str, Any]] = {}
self._ctrl_rows: dict[str, Gtk.Widget] = {}
self._resetting = False
+ self._closed = False
+ self._generation = 0
+ self._commands = LatestCommands()
+ self._fetch_task = None
# Auto-controls that disable their dependent manual controls.
# Key = auto control ID, value = (dependent IDs, disable-when values).
self._DEPENDENCIES: dict[str, tuple[list[str], set[int]]] = {
"auto_exposure": (
["exposure_time_absolute", "exposure_absolute"],
- {3}, # 3 = Aperture Priority → disable manual exposure
+ {0, 3}, # Auto / aperture priority: manual shutter is inactive.
),
"white_balance_automatic": (
["white_balance_temperature"],
@@ -101,12 +107,20 @@ def set_camera_with_controls(
controls: list[CameraControl],
) -> None:
"""Set camera and display pre-fetched controls (avoids USB conflict)."""
+ self._generation += 1
+ self._commands.invalidate()
+ if self._fetch_task:
+ self._fetch_task.cancel()
self._camera = camera
self._controls = controls
self._clear_content()
self._populate(controls)
def set_camera(self, camera: CameraInfo | None) -> None:
+ self._generation += 1
+ self._commands.invalidate()
+ if self._fetch_task:
+ self._fetch_task.cancel()
self._camera = camera
self._clear_content()
if camera is None:
@@ -125,22 +139,20 @@ def set_camera(self, camera: CameraInfo | None) -> None:
spinner_box.append(Gtk.Label(label=_("Loading controls…")))
self._content.append(spinner_box)
- def fetch_controls() -> list[CameraControl]:
- return self._manager.get_controls(camera)
-
- def on_controls(controls: list[CameraControl]) -> None:
- # Ensure we're still on the same camera
- if self._camera is not camera:
+ generation = self._generation
+ def done(controls):
+ if self._closed or generation != self._generation:
return
self._controls = controls
self._clear_content()
self._populate(controls)
-
- def _bg_fetch():
- ctrls = fetch_controls()
- GLib.idle_add(on_controls, ctrls)
-
- threading.Thread(target=_bg_fetch, daemon=True).start()
+ def failed(exc):
+ if not self._closed and generation == self._generation:
+ self._clear_content()
+ self._content.append(Adw.StatusPage(title=_("Could not load camera controls"),
+ description=_("Check the connection and try again.")))
+ self._fetch_task = run_async(lambda: self._manager.get_controls(camera),
+ on_success=done, on_error=failed)
def _clear_content(self) -> None:
child = self._content.get_first_child()
@@ -148,6 +160,8 @@ def _clear_content(self) -> None:
next_child = child.get_next_sibling()
self._content.remove(child)
child = next_child
+ for timer in self._debounce_sources.values():
+ GLib.source_remove(timer)
self._debounce_sources.clear()
self._ctrl_widgets.clear()
self._ctrl_rows.clear()
@@ -253,8 +267,8 @@ def _make_row(self, ctrl: CameraControl) -> Gtk.Widget | None:
row.update_property([Gtk.AccessibleProperty.LABEL], [ctrl.name])
adj = Gtk.Adjustment(
value=float(ctrl.value or 0),
- lower=float(ctrl.minimum or 0),
- upper=float(ctrl.maximum or 100),
+ lower=float(ctrl.minimum if ctrl.minimum is not None else 0),
+ upper=float(ctrl.maximum if ctrl.maximum is not None else 100),
step_increment=float(ctrl.step or 1),
)
scale = Gtk.Scale(
@@ -264,20 +278,7 @@ def _make_row(self, ctrl: CameraControl) -> Gtk.Widget | None:
draw_value=False,
)
scale.set_size_request(140, -1)
- scale.update_property(
- [
- Gtk.AccessibleProperty.LABEL,
- Gtk.AccessibleProperty.VALUE_NOW,
- Gtk.AccessibleProperty.VALUE_MIN,
- Gtk.AccessibleProperty.VALUE_MAX,
- ],
- [
- ctrl.name,
- float(adj.get_value()),
- float(adj.get_lower()),
- float(adj.get_upper()),
- ],
- )
+ scale.update_property([Gtk.AccessibleProperty.LABEL], [ctrl.name])
spin = Gtk.SpinButton(
adjustment=adj,
climb_rate=1.0,
@@ -311,6 +312,7 @@ def _make_row(self, ctrl: CameraControl) -> Gtk.Widget | None:
else:
row = Adw.EntryRow(title=ctrl.name)
row.set_text(str(ctrl.value or ""))
+ row.set_show_apply_button(True)
row.connect("apply", self._on_entry_apply, ctrl)
row.update_property([Gtk.AccessibleProperty.LABEL], [ctrl.name])
row.set_sensitive(True)
@@ -384,41 +386,29 @@ def _refresh_profile_list(self) -> None:
for name in self._profile_names:
self._profile_model.append(name)
- def _on_profile_selected(self, row: Adw.ComboRow, _pspec: Any) -> None:
- if self._resetting or not self._camera:
- return
- idx = row.get_selected()
- if idx == Gtk.INVALID_LIST_POSITION or idx >= len(self._profile_names):
+ def _on_profile_selected(self, row, _pspec):
+ if self._resetting or not self._camera or self._closed:
return
- name = self._profile_names[idx]
- values = camera_profiles.load_profile(self._camera, name)
- if not values:
+ index = row.get_selected()
+ if index >= len(self._profile_names):
return
- self._resetting = True
- for ctrl in self._controls:
- if ctrl.id in values:
- val = values[ctrl.id]
- ctrl.value = val
- entry = self._ctrl_widgets.get(ctrl.id)
- if entry:
- kind, widget = entry
- if kind == "bool":
- widget.set_active(bool(val))
- elif kind == "menu" and isinstance(val, int) and ctrl.choice_values:
- try:
- widget.set_selected(ctrl.choice_values.index(val))
- except ValueError:
- pass
- elif kind == "int":
- widget.set_value(float(val))
- threading.Thread(
- target=lambda c=ctrl, v=val: self._manager.set_control(
- self._camera, c.id, v
- ),
- daemon=True,
- ).start()
- self._resetting = False
- self._update_all_dependencies(self._controls)
+ camera = self._camera
+ generation = self._generation
+ name = self._profile_names[index]
+ controls = {control.id: control for control in self._controls}
+ def apply_profile():
+ values = camera_profiles.load_profile(camera, name)
+ # Auto modes precede dependent manual values; never write unknown controls.
+ keys = sorted(values, key=lambda key: (key not in self._DEPENDENCIES, key))
+ for key in keys:
+ if self._closed or generation != self._generation:
+ return
+ control = controls.get(key)
+ if control and "read-only" not in (control.flags or ""):
+ if not self._manager.set_control(camera, key, values[key]):
+ raise RuntimeError("A profile control was rejected")
+ run_async(apply_profile, on_success=lambda _result: self._reload_controls(),
+ on_error=lambda exc: self._report_error(_("Some profile controls could not be applied.")))
def _on_save_profile(self, _btn: Gtk.Button) -> None:
if not self._camera:
@@ -446,7 +436,13 @@ def on_response(_dlg: Adw.MessageDialog, response: str) -> None:
name = entry.get_text().strip()
if not name:
return
- camera_profiles.save_profile(self._camera, name, self._controls)
+ try:
+ saved_path = camera_profiles.save_profile(self._camera, name, self._controls)
+ import os
+ name = os.path.splitext(os.path.basename(saved_path))[0]
+ except (OSError, ValueError):
+ self._report_error(_("Could not save the profile. Check its name and folder permissions."))
+ return
self._refresh_profile_list()
if name in self._profile_names:
self._profile_row.set_selected(self._profile_names.index(name))
@@ -464,48 +460,14 @@ def _on_delete_profile(self, _btn: Gtk.Button) -> None:
camera_profiles.delete_profile(self._camera, name)
self._refresh_profile_list()
- def _on_hardware_reset(self, _btn: Gtk.Button) -> None:
- """Reset all V4L2 controls to hardware default values."""
- if not self._camera or not self._controls:
- return
- import threading
-
- def _apply():
- self._manager.reset_all_controls(self._camera, self._controls)
- # Re-apply anti-flicker after reset (power_line_frequency defaults to 0)
- self._manager.apply_anti_flicker(self._camera)
- GLib.idle_add(self._reload_controls)
-
- threading.Thread(target=_apply, daemon=True).start()
+ def _on_hardware_reset(self, _btn):
+ if self._camera:
+ self._on_reset(_btn, list(self._controls))
- def _reload_controls(self) -> bool:
- """Refresh UI with current control values from hardware."""
- if not self._camera:
- return False
- controls = self._manager.get_controls(self._camera)
- if controls is None:
- return False
- self._controls = controls
- self._resetting = True
- for ctrl in controls:
- widget_info = self._ctrl_widgets.get(ctrl.id)
- if not widget_info:
- continue
- wtype, widget = widget_info
- if wtype == "int" and isinstance(widget, Gtk.Adjustment):
- widget.set_value(float(ctrl.value or 0))
- elif wtype == "bool" and isinstance(widget, Adw.SwitchRow):
- widget.set_active(bool(ctrl.value))
- elif wtype == "menu" and isinstance(widget, Adw.ComboRow):
- if isinstance(ctrl.value, int) and ctrl.choice_values:
- try:
- sel = ctrl.choice_values.index(ctrl.value)
- widget.set_selected(sel)
- except ValueError:
- pass
- self._update_all_dependencies(controls)
- self._resetting = False
- return False
+ def _reload_controls(self):
+ if not self._closed and self._camera:
+ self.set_camera(self._camera)
+ return GLib.SOURCE_REMOVE
# -- control dependencies -------------------------------------------------
@@ -568,42 +530,32 @@ def _apply_scale(self, adj: Gtk.Adjustment, ctrl: CameraControl) -> bool:
self._apply(ctrl, int(adj.get_value()))
return False
- def _apply(self, ctrl: CameraControl, value: Any) -> None:
- if self._camera:
- # Run v4l2-ctl subprocess in background to avoid blocking UI
- camera = self._camera
- threading.Thread(
- target=lambda: self._manager.set_control(camera, ctrl.id, value),
- daemon=True,
- ).start()
- # Apply software zoom as fallback for cameras where V4L2 zoom is ineffective
- if ctrl.id == "zoom_absolute" and self._engine is not None:
- v4l_min = ctrl.minimum or 0
- v4l_max = ctrl.maximum or 10
- rng = max(v4l_max - v4l_min, 1)
- level = 1.0 + (int(value) - v4l_min) / rng * 3.0 # 1x-4x
- self._engine.set_zoom(level)
- # Apply software sharpness as fallback
- if ctrl.id == "sharpness" and self._engine is not None:
- v4l_min = ctrl.minimum or 0
- v4l_max = ctrl.maximum or 50
- rng = max(v4l_max - v4l_min, 1)
- level = (int(value) - v4l_min) / rng # 0.0-1.0
- self._engine.set_sharpness(level)
- # Apply software pan as fallback
- if ctrl.id == "pan_absolute" and self._engine is not None:
- v4l_min = ctrl.minimum or -201600
- v4l_max = ctrl.maximum or 201600
- rng = max(v4l_max - v4l_min, 1)
- level = ((int(value) - v4l_min) / rng) * 2.0 - 1.0 # -1.0 to 1.0
- self._engine.set_pan(level)
- # Apply software tilt as fallback
- if ctrl.id == "tilt_absolute" and self._engine is not None:
- v4l_min = ctrl.minimum or -201600
- v4l_max = ctrl.maximum or 201600
- rng = max(v4l_max - v4l_min, 1)
- level = ((int(value) - v4l_min) / rng) * 2.0 - 1.0 # -1.0 to 1.0
- self._engine.set_tilt(level)
+ def _apply(self, ctrl, value):
+ camera = self._camera
+ generation = self._generation
+ if self._closed or camera is None or "read-only" in (ctrl.flags or ""):
+ return
+ def apply():
+ if not self._manager.set_control(camera, ctrl.id, value):
+ raise RuntimeError("Camera rejected the control")
+ return value
+ def done(confirmed):
+ if not self._closed and generation == self._generation:
+ ctrl.value = confirmed
+ if ctrl.id in self._DEPENDENCIES:
+ self._reload_controls()
+ def failed(exc):
+ if not self._closed and generation == self._generation:
+ self._report_error(_("The camera could not apply this setting."))
+ self._commands.submit(ctrl.id, apply, done, failed)
+
+
+ def _report_error(self, message):
+ if self._closed:
+ return
+ root = self.get_root()
+ if root and hasattr(root, "_show_notification"):
+ root._show_notification(message, "error", 0)
def _on_entry_apply(self, row: Adw.EntryRow, ctrl: CameraControl) -> None:
self._apply(ctrl, row.get_text())
@@ -623,37 +575,35 @@ def _make_reset_button(
btn.connect("clicked", self._on_reset, ctrls)
return btn
- def _on_reset(self, _btn: Gtk.Button, ctrls: list[CameraControl]) -> None:
- if self._camera:
- self._resetting = True
- self._manager.reset_all_controls(self._camera, ctrls)
- # Re-apply anti-flicker after reset (power_line_frequency defaults to 0)
- self._manager.apply_anti_flicker(self._camera)
- for ctrl in ctrls:
- ctrl.value = ctrl.default
- entry = self._ctrl_widgets.get(ctrl.id)
- if not entry:
- continue
- kind, widget = entry
- if kind == "bool":
- widget.set_active(bool(ctrl.default))
- elif kind == "menu":
- if isinstance(ctrl.default, int) and ctrl.choices:
- idx = ctrl.default - (ctrl.minimum or 0)
- if 0 <= idx < len(ctrl.choices):
- widget.set_selected(idx)
- elif kind == "int":
- widget.set_value(float(ctrl.default or 0))
- # Reset software zoom if zoom control is reset
- if ctrl.id == "zoom_absolute" and self._engine is not None:
- self._engine.set_zoom(1.0)
- # Reset software sharpness if sharpness control is reset
- if ctrl.id == "sharpness" and self._engine is not None:
- self._engine.set_sharpness(0.0)
- # Reset software pan if control is reset
- if ctrl.id == "pan_absolute" and self._engine is not None:
- self._engine.set_pan(0.0)
- # Reset software tilt if control is reset
- if ctrl.id == "tilt_absolute" and self._engine is not None:
- self._engine.set_tilt(0.0)
- self._resetting = False
+ def _on_reset(self, _btn, controls):
+ camera = self._camera
+ if not camera or self._closed:
+ return
+ generation = self._generation
+ self._commands.invalidate()
+ def reset():
+ self._manager.reset_all_controls(camera, controls)
+ def done(_result):
+ if not self._closed and generation == self._generation:
+ if self._engine:
+ self._engine.set_zoom(1)
+ self._engine.set_sharpness(0)
+ self._engine.set_pan(0)
+ self._engine.set_tilt(0)
+ self._reload_controls()
+ run_async(reset, on_success=done, on_error=lambda exc: self._report_error(_("Could not reset camera controls.")))
+
+ def cleanup(self):
+ self._closed = True
+ self._generation += 1
+ self._commands.invalidate()
+ if self._fetch_task:
+ self._fetch_task.cancel()
+ for timer in getattr(self, "_debounce_sources", {}).values():
+ GLib.source_remove(timer)
+ getattr(self, "_debounce_sources", {}).clear()
+ for name in ("_qr_timer_id",):
+ timer = getattr(self, name, None)
+ if timer:
+ GLib.source_remove(timer)
+ setattr(self, name, None)
diff --git a/usr/share/biglinux/bigcam/ui/controllers/mobile_device_ctrl.py b/usr/share/biglinux/bigcam/ui/controllers/mobile_device_ctrl.py
index 51a6776..0d105c0 100644
--- a/usr/share/biglinux/bigcam/ui/controllers/mobile_device_ctrl.py
+++ b/usr/share/biglinux/bigcam/ui/controllers/mobile_device_ctrl.py
@@ -29,24 +29,25 @@ def __init__(self, camera_manager, immersion_controller, audio_monitor=None):
self.scrcpy_wifi = ScrcpyCamera()
self.airplay_receiver = AirPlayReceiver()
+ self._signal_ids = []
self._setup_signals()
def _setup_signals(self):
- self.phone_server.connect("connected", self._on_phone_connected)
- self.phone_server.connect("disconnected", self._on_phone_disconnected)
- self.phone_server.connect("status-changed", lambda s, st: event_bus.emit("mobile-status-changed", "phone", st))
+ self._connect(self.phone_server, "connected", self._on_phone_connected)
+ self._connect(self.phone_server, "disconnected", self._on_phone_disconnected)
+ self._connect(self.phone_server, "status-changed", lambda s, st: event_bus.emit("mobile-status-changed", "phone", st))
- self.scrcpy_usb.connect("status-changed", lambda c, st: event_bus.emit("mobile-status-changed", "scrcpy_usb", st))
- self.scrcpy_usb.connect("connected", self._on_scrcpy_receiver_connected)
- self.scrcpy_usb.connect("disconnected", self._on_scrcpy_receiver_disconnected)
+ self._connect(self.scrcpy_usb, "status-changed", lambda c, st: event_bus.emit("mobile-status-changed", "scrcpy_usb", st))
+ self._connect(self.scrcpy_usb, "connected", self._on_scrcpy_receiver_connected)
+ self._connect(self.scrcpy_usb, "disconnected", self._on_scrcpy_receiver_disconnected)
- self.scrcpy_wifi.connect("status-changed", lambda c, st: event_bus.emit("mobile-status-changed", "scrcpy_wifi", st))
- self.scrcpy_wifi.connect("connected", self._on_scrcpy_receiver_connected)
- self.scrcpy_wifi.connect("disconnected", self._on_scrcpy_receiver_disconnected)
+ self._connect(self.scrcpy_wifi, "status-changed", lambda c, st: event_bus.emit("mobile-status-changed", "scrcpy_wifi", st))
+ self._connect(self.scrcpy_wifi, "connected", self._on_scrcpy_receiver_connected)
+ self._connect(self.scrcpy_wifi, "disconnected", self._on_scrcpy_receiver_disconnected)
- self.airplay_receiver.connect("status-changed", lambda r, st: event_bus.emit("mobile-status-changed", "airplay", st))
- self.airplay_receiver.connect("connected", self._on_airplay_receiver_connected)
- self.airplay_receiver.connect("disconnected", self._on_airplay_receiver_disconnected)
+ self._connect(self.airplay_receiver, "status-changed", lambda r, st: event_bus.emit("mobile-status-changed", "airplay", st))
+ self._connect(self.airplay_receiver, "connected", self._on_airplay_receiver_connected)
+ self._connect(self.airplay_receiver, "disconnected", self._on_airplay_receiver_disconnected)
def show_dialog(self, parent_window):
"""Shows the mobile connection dialog."""
@@ -115,7 +116,14 @@ def _on_scrcpy_receiver_connected(self, camera: ScrcpyCamera, width: int, height
self._camera_manager.add_phone_camera(cam_info)
event_bus.emit("camera-changed", cam_info)
+ if self._audio_monitor:
+ self._audio_monitor.add_external_source(
+ "scrcpy_usb" if camera is self.scrcpy_usb else "scrcpy_wifi", cam_info.name, pid=camera.pid)
+
def _on_scrcpy_receiver_disconnected(self, camera: ScrcpyCamera) -> None:
+ if self._audio_monitor:
+ self._audio_monitor.remove_external_source(
+ "scrcpy_usb" if camera is self.scrcpy_usb else "scrcpy_wifi")
device_id = camera.device_serial
if device_id:
self._camera_manager.remove_scrcpy_camera(device_id)
@@ -134,7 +142,23 @@ def _on_airplay_receiver_connected(self, receiver: AirPlayReceiver, width: int,
self._camera_manager.add_phone_camera(cam_info)
event_bus.emit("camera-changed", cam_info)
+ if self._audio_monitor:
+ self._audio_monitor.add_external_source("airplay", cam_info.name, pid=receiver.pid)
+
def _on_airplay_receiver_disconnected(self, receiver: AirPlayReceiver) -> None:
+ if self._audio_monitor:
+ self._audio_monitor.remove_external_source("airplay")
self._camera_manager.remove_airplay_cameras()
event_bus.emit("camera-changed", None)
+
+ def _connect(self, obj, signal, callback):
+ self._signal_ids.append((obj, obj.connect(signal, callback)))
+
+ def disconnect_signals(self):
+ if self._phone_disconnect_timer:
+ GLib.source_remove(self._phone_disconnect_timer)
+ self._phone_disconnect_timer = None
+ for obj, ident in self._signal_ids:
+ obj.disconnect(ident)
+ self._signal_ids.clear()
diff --git a/usr/share/biglinux/bigcam/ui/controllers/sidebar_ctrl.py b/usr/share/biglinux/bigcam/ui/controllers/sidebar_ctrl.py
index 1ed31c9..5390eee 100644
--- a/usr/share/biglinux/bigcam/ui/controllers/sidebar_ctrl.py
+++ b/usr/share/biglinux/bigcam/ui/controllers/sidebar_ctrl.py
@@ -8,7 +8,7 @@
from gi.repository import Gtk, Gdk, Adw
from constants import APP_NAME, APP_ICON
-from core.event_bus import event_bus
+from utils.i18n import _
class SidebarController:
"""Manages the Sidebar ViewStack and Header."""
@@ -33,6 +33,7 @@ def _build(self, stack_pages: dict[str, tuple[Gtk.Widget, str, str]]) -> None:
drag_handle.set_cursor(Gdk.Cursor.new_from_name("col-resize"))
drag_handle.add_css_class("sidebar-drag-handle")
drag_gesture = Gtk.GestureDrag()
+ drag_gesture.connect("drag-begin", self._on_drag_begin)
drag_gesture.connect("drag-update", self._on_sidebar_drag)
drag_handle.add_controller(drag_gesture)
@@ -59,6 +60,7 @@ def _build(self, stack_pages: dict[str, tuple[Gtk.Widget, str, str]]) -> None:
close_sidebar_btn = Gtk.Button.new_from_icon_name("window-close-symbolic")
close_sidebar_btn.add_css_class("flat")
+ close_sidebar_btn.update_property([Gtk.AccessibleProperty.LABEL], [_("Close sidebar")])
close_sidebar_btn.connect("clicked", lambda _b: self._split_view.set_show_sidebar(False))
sidebar_header.pack_end(close_sidebar_btn)
@@ -70,7 +72,7 @@ def _build(self, stack_pages: dict[str, tuple[Gtk.Widget, str, str]]) -> None:
tab_bar.add_css_class("sidebar-tab-bar")
group_btn = None
- for page_name, (_, title, icon_name) in stack_pages.items():
+ for page_name, (_widget, title, icon_name) in stack_pages.items():
btn = Gtk.ToggleButton()
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
box.set_halign(Gtk.Align.CENTER)
@@ -79,6 +81,7 @@ def _build(self, stack_pages: dict[str, tuple[Gtk.Widget, str, str]]) -> None:
box.append(icon)
label = Gtk.Label(label=title)
label.add_css_class("caption")
+ label.set_wrap(True)
box.append(label)
btn.set_child(box)
btn.add_css_class("flat")
@@ -102,11 +105,21 @@ def _on_sidebar_tab_toggled(self, btn: Gtk.ToggleButton, page_name: str) -> None
if btn.get_active():
self._view_stack.set_visible_child_name(page_name)
- def _on_sidebar_drag(self, gesture: Gtk.GestureDrag, offset_x: float, _offset_y: float) -> None:
- # Simplistic drag-to-resize logic (ported from window.py)
- start_x, _ = gesture.get_start_point()
- current_width = self._split_view.get_sidebar_width_fraction()
- # Roughly convert pixel delta to fraction delta
- delta = -(offset_x / 1000.0)
- new_width = max(0.2, min(0.5, current_width + delta))
- self._split_view.set_sidebar_width_fraction(new_width)
+ @property
+ def stack(self):
+ return self._view_stack
+
+
+ def _on_drag_begin(self, gesture, start_x, start_y):
+ self._drag_start_width = self._split_view.get_max_sidebar_width()
+
+
+ def _on_sidebar_drag(self, gesture, offset_x, offset_y):
+ width = self._split_view.get_width()
+ delta = offset_x if self._split_view.get_sidebar_position() == Gtk.PackType.START else -offset_x
+ if self._split_view.get_direction() == Gtk.TextDirection.RTL:
+ delta = -delta
+ target = max(280, min(max(280, width - 160), self._drag_start_width + delta))
+ self._split_view.set_max_sidebar_width(target)
+ self._split_view.set_min_sidebar_width(min(target, 280))
+ self._split_view.set_sidebar_width_fraction(min(1.0, target / max(width, 1)))
diff --git a/usr/share/biglinux/bigcam/ui/effects_page.py b/usr/share/biglinux/bigcam/ui/effects_page.py
index 0429c92..21156af 100644
--- a/usr/share/biglinux/bigcam/ui/effects_page.py
+++ b/usr/share/biglinux/bigcam/ui/effects_page.py
@@ -45,6 +45,7 @@ def __init__(self, effect_pipeline: EffectPipeline) -> None:
hscrollbar_policy=Gtk.PolicyType.NEVER,
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
)
+ self._closed = False
self._pipeline = effect_pipeline
self._debounce_sources: dict[str, int] = {}
self._effect_widgets: dict[str, dict[str, Any]] = {}
@@ -128,12 +129,13 @@ def _add_effect_rows(self, group: Adw.PreferencesGroup, effect: EffectInfo) -> N
# Add switch as suffix (independent of expansion)
switch = Gtk.Switch()
switch.set_active(effect.enabled)
+ switch.update_property([Gtk.AccessibleProperty.LABEL], [effect.name])
switch.set_valign(Gtk.Align.CENTER)
switch.connect("notify::active", self._on_switch_toggle, effect)
expander.add_suffix(switch)
self._effect_widgets[effect.effect_id] = {"switch": switch, "params": {}}
# Replace internal arrow icon
- self._replace_arrow_icon(expander, "pan-up-symbolic")
+ # Keep libadwaita's native RTL- and state-aware disclosure indicator.
for param in effect.params:
param_row = self._make_param_row(effect, param)
expander.add_row(param_row)
@@ -178,7 +180,8 @@ def _make_param_row(self, effect: EffectInfo, param: EffectParam) -> Adw.ActionR
draw_value=True,
value_pos=Gtk.PositionType.LEFT,
)
- scale.set_size_request(180, -1)
+ scale.set_size_request(120, -1)
+ scale.update_property([Gtk.AccessibleProperty.LABEL], [param.label])
# Set digits based on step
if param.step >= 1:
@@ -228,6 +231,8 @@ def _replace_arrow_icon(widget: Gtk.Widget, icon_name: str) -> None:
def _on_param_changed(
self, adj: Gtk.Adjustment, effect: EffectInfo, param: EffectParam
) -> None:
+ if self._resetting or self._closed:
+ return
key = f"{effect.effect_id}_{param.name}"
if key in self._debounce_sources:
GLib.source_remove(self._debounce_sources[key])
@@ -302,6 +307,19 @@ def _rebuild(self) -> None:
next_c = child.get_next_sibling()
self._content.remove(child)
child = next_c
+ for timer in self._debounce_sources.values():
+ GLib.source_remove(timer)
self._debounce_sources.clear()
self._effect_widgets.clear()
self._build_ui()
+
+ def cleanup(self):
+ self._closed = True
+ for timer in getattr(self, "_debounce_sources", {}).values():
+ GLib.source_remove(timer)
+ getattr(self, "_debounce_sources", {}).clear()
+ for name in ("_qr_timer_id",):
+ timer = getattr(self, name, None)
+ if timer:
+ GLib.source_remove(timer)
+ setattr(self, name, None)
diff --git a/usr/share/biglinux/bigcam/ui/immersion.py b/usr/share/biglinux/bigcam/ui/immersion.py
index 3654e02..5084f9d 100644
--- a/usr/share/biglinux/bigcam/ui/immersion.py
+++ b/usr/share/biglinux/bigcam/ui/immersion.py
@@ -54,6 +54,7 @@ def __init__(self, window: Gtk.Window) -> None:
self._header_revealer: Gtk.Revealer | None = None
self._extra_revealers: list[Gtk.Revealer] = []
self._fade_widgets: list[Gtk.Widget] = []
+ self._target_defaults = {}
self._split_view: Adw.OverlaySplitView | None = None
self._root_box: Gtk.Widget | None = None
@@ -92,6 +93,7 @@ def add_fade_widget(self, widget: Gtk.Widget) -> None:
"""Register a widget whose opacity will be animated on hide/show."""
if widget not in self._fade_widgets:
self._fade_widgets.append(widget)
+ self._target_defaults[widget] = widget.get_can_target()
def add_revealer(self, revealer: Gtk.Revealer) -> None:
"""Register an extra revealer to hide/show alongside the header."""
@@ -190,6 +192,14 @@ def _is_any_popover_mapped(self, widget: Gtk.Widget) -> bool:
return False
def _on_inactivity_timeout(self) -> bool:
+ settings = getattr(self._window, "_settings", None)
+ if settings and not settings.get("auto-hide-controls"):
+ self._timer_id = None
+ return GLib.SOURCE_REMOVE
+ focus = self._window.get_focus()
+ if focus is not None and focus.get_visible():
+ self._timer_id = None
+ return GLib.SOURCE_REMOVE
if self._inhibit_count == 0:
if self._is_any_popover_mapped(self._window):
return True # Keep timeout alive while popover is open
@@ -206,7 +216,9 @@ def _begin_fade_out(self) -> None:
if self._is_immersed:
return
self._is_immersed = True
- self._fade_step = 0
+ settings = getattr(self._window, "_settings", None)
+ reduced = bool(settings and settings.get("reduce-motion")) or not Gtk.Settings.get_default().get_property("gtk-enable-animations")
+ self._fade_step = _FADE_STEPS - 1 if reduced else 0
# Header revealer: smooth slide-up
if self._header_revealer:
@@ -284,7 +296,7 @@ def _show_ui(self) -> None:
# Widgets: instant full opacity + re-enable interaction
for w in self._fade_widgets:
w.set_opacity(1.0)
- w.set_can_target(True)
+ w.set_can_target(self._target_defaults[w])
# Restore mouse cursor
self._window.set_cursor(None)
diff --git a/usr/share/biglinux/bigcam/ui/media_gallery.py b/usr/share/biglinux/bigcam/ui/media_gallery.py
new file mode 100644
index 0000000..fd0542a
--- /dev/null
+++ b/usr/share/biglinux/bigcam/ui/media_gallery.py
@@ -0,0 +1,291 @@
+"""Shared, paginated gallery with bounded asynchronous metadata and reversible trash."""
+import os
+import subprocess
+import time
+from collections import deque
+
+import gi
+
+gi.require_version("Gtk", "4.0")
+gi.require_version("Adw", "1")
+from core.media_library import PHOTO_EXTS, VIDEO_EXTS, duration, scan, thumbnail
+from gi.repository import Adw, Gdk, Gio, GLib, Gtk
+from utils.async_worker import run_async
+from utils.i18n import _, ngettext
+from utils.settings_manager import SettingsManager
+
+
+class MediaGallery(Gtk.Box):
+ PAGE_SIZE = 100
+
+ def __init__(self, kind, directory):
+ super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ self._kind = kind
+ self._directory = directory
+ self._settings = SettingsManager()
+ self._view = self._settings.get(f"gallery-{kind}-view", "grid")
+ self._closed = False
+ self._generation = 0
+ self._limit = self.PAGE_SIZE
+ self._selected = set()
+ self._entries = []
+ self._selection_mode = False
+ self._scope = {"alive": False}
+ header = Gtk.Box(spacing=6, margin_top=12, margin_start=12, margin_end=12)
+ title = Gtk.Label(label=_("Captured Photos") if kind == "photo" else _("Recorded Videos"),
+ hexpand=True, xalign=0, wrap=True)
+ title.add_css_class("heading")
+ header.append(title)
+ for view, icon, label in [("grid", "view-grid-symbolic", _("Grid view")),
+ ("list", "view-list-symbolic", _("List view"))]:
+ button = Gtk.Button(icon_name=icon, tooltip_text=label)
+ button.update_property([Gtk.AccessibleProperty.LABEL], [label])
+ button.connect("clicked", lambda _button, target=view: self._set_view(target))
+ header.append(button)
+ selection = Gtk.ToggleButton(icon_name="object-select-symbolic", tooltip_text=_("Select items"))
+ selection.update_property([Gtk.AccessibleProperty.LABEL], [_("Select items")])
+ selection.connect("toggled", self._toggle_selection)
+ header.append(selection)
+ self.append(header)
+ self._status = Gtk.Label(wrap=True, margin_start=12, margin_end=12)
+ self._status.update_property([Gtk.AccessibleProperty.LABEL], [_("Gallery status")])
+ self.append(self._status)
+ self._stack = Gtk.Stack(vexpand=True)
+ self._grid = Gtk.FlowBox(selection_mode=Gtk.SelectionMode.NONE, homogeneous=True,
+ min_children_per_line=1, max_children_per_line=6, column_spacing=8, row_spacing=8)
+ self._list = Gtk.ListBox(selection_mode=Gtk.SelectionMode.NONE)
+ self._list.add_css_class("boxed-list")
+ self._stack.add_named(self._grid, "grid")
+ self._stack.add_named(self._list, "list")
+ scroll = Gtk.ScrolledWindow(vexpand=True, hscrollbar_policy=Gtk.PolicyType.NEVER)
+ scroll.set_child(self._stack)
+ self.append(scroll)
+ actions = Gtk.Box(spacing=6, margin_start=12, margin_end=12, margin_bottom=12)
+ self._more = Gtk.Button(label=_("Load more"))
+ self._more.connect("clicked", self._load_more)
+ actions.append(self._more)
+ refresh = Gtk.Button(icon_name="view-refresh-symbolic", tooltip_text=_("Refresh"))
+ refresh.update_property([Gtk.AccessibleProperty.LABEL], [_("Refresh gallery")])
+ refresh.connect("clicked", lambda _button: self.refresh())
+ actions.append(refresh)
+ folder = Gtk.Button(icon_name="folder-open-symbolic", tooltip_text=_("Open folder"))
+ folder.update_property([Gtk.AccessibleProperty.LABEL], [_("Open media folder")])
+ folder.connect("clicked", lambda _button: self._open(self._directory))
+ actions.append(folder)
+ self.append(actions)
+ self._selection_bar = Gtk.Box(spacing=6, visible=False, margin_start=12, margin_end=12, margin_bottom=12)
+ self._select_all = Gtk.Button(label=_("Select displayed items"))
+ self._select_all.connect("clicked", self._on_select_all)
+ self._selection_bar.append(self._select_all)
+ trash = Gtk.Button(label=_("Move to Trash"))
+ trash.add_css_class("destructive-action")
+ trash.connect("clicked", lambda _button: self._confirm_trash(list(self._selected)))
+ self._selection_bar.append(trash)
+ self.append(self._selection_bar)
+ self.connect("map", lambda _widget: self.refresh())
+
+ def _set_view(self, view):
+ self._view = view
+ self._settings.set(f"gallery-{self._kind}-view", view)
+ self._rebuild()
+
+ def _toggle_selection(self, button):
+ self._selection_mode = button.get_active()
+ self._selected.clear()
+ self._selection_bar.set_visible(self._selection_mode)
+ self._rebuild()
+
+ def _load_more(self, _button):
+ self._limit += self.PAGE_SIZE
+ self._rebuild()
+
+ def refresh(self):
+ if self._closed:
+ return
+ self._generation += 1
+ generation = self._generation
+ self._status.set_label(_("Loading media…"))
+ extensions = PHOTO_EXTS if self._kind == "photo" else VIDEO_EXTS
+ def done(entries):
+ if self._closed or generation != self._generation:
+ return
+ self._entries = entries
+ self._selected.intersection_update(entry.path for entry in entries)
+ self._rebuild()
+ run_async(lambda: scan(self._directory, extensions), on_success=done,
+ on_error=lambda exc: self._error(_("Could not read the media folder.")))
+
+ def _rebuild(self):
+ if self._closed:
+ return
+ self._scope["alive"] = False
+ scope = self._scope = {"alive": True, "pending": deque(), "active": 0}
+ for container in (self._grid, self._list):
+ child = container.get_first_child()
+ while child:
+ following = child.get_next_sibling()
+ container.remove(child)
+ child = following
+ self._stack.set_visible_child_name("list" if self._view == "list" else "grid")
+ for entry in self._entries[:self._limit]:
+ picture = Gtk.Picture(content_fit=Gtk.ContentFit.CONTAIN)
+ picture.set_size_request(48 if self._view == "list" else 160, 48 if self._view == "list" else 160)
+ picture.set_alternative_text(entry.name)
+ length = Gtk.Label() if entry.is_video else None
+ if self._view == "list":
+ row = Adw.ActionRow(title=entry.name, subtitle=f"{GLib.format_size(entry.size)} · {time.strftime('%x %X', time.localtime(entry.modified))}")
+ row.add_prefix(picture)
+ row.set_activatable(True)
+ row.connect("activated", lambda _row, path=entry.path: self._activate(path))
+ self._list.append(row)
+ suffix = row.add_suffix
+ if length:
+ suffix(length)
+ else:
+ row = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ preview = Gtk.Overlay(child=picture)
+ if length:
+ play = Gtk.Image(icon_name="media-playback-start-symbolic", pixel_size=32,
+ halign=Gtk.Align.CENTER, valign=Gtk.Align.CENTER, can_target=False)
+ play.add_css_class("osd")
+ preview.add_overlay(play)
+ length.set_halign(Gtk.Align.END)
+ length.set_valign(Gtk.Align.END)
+ length.set_margin_end(4)
+ length.set_margin_bottom(4)
+ length.add_css_class("osd")
+ preview.add_overlay(length)
+ button = Gtk.Button(child=preview, tooltip_text=entry.name)
+ button.update_property([Gtk.AccessibleProperty.LABEL], [_("Open %s") % entry.name])
+ button.connect("clicked", lambda _button, path=entry.path: self._activate(path))
+ row.append(button)
+ self._grid.append(row)
+ suffix = row.append
+ if self._selection_mode:
+ check = Gtk.CheckButton(label=_("Select"), active=entry.path in self._selected)
+ check.update_property([Gtk.AccessibleProperty.LABEL], [_("Select %s") % entry.name])
+ check.connect("toggled", self._select, entry.path)
+ suffix(check)
+ else:
+ trash = Gtk.Button(icon_name="user-trash-symbolic", valign=Gtk.Align.CENTER, tooltip_text=_("Move to Trash"))
+ trash.update_property([Gtk.AccessibleProperty.LABEL], [_("Move %s to Trash") % entry.name])
+ trash.connect("clicked", lambda _button, path=entry.path: self._confirm_trash([path]))
+ suffix(trash)
+ scope["pending"].append((entry, picture, length))
+ self._more.set_visible(len(self._entries) > self._limit)
+ self._update_count()
+ self._pump(scope)
+
+ def _pump(self, scope):
+ while scope["alive"] and scope["pending"] and scope["active"] < 2:
+ entry, picture, length = scope["pending"].popleft()
+ scope["active"] += 1
+ def loaded(result, image=picture, label=length, current=scope):
+ current["active"] -= 1
+ if current["alive"] and not self._closed:
+ path, seconds = result
+ if path:
+ try:
+ image.set_filename(path)
+ except GLib.Error:
+ pass
+ if label:
+ label.set_label(seconds)
+ self._pump(current)
+ def metadata(item=entry):
+ path = thumbnail(item)
+ try:
+ seconds = duration(item)
+ except (OSError, ValueError, subprocess.SubprocessError):
+ seconds = ""
+ return path, seconds
+ run_async(metadata, on_success=loaded,
+ on_error=lambda exc, callback=loaded: callback((None, "")))
+
+ def _activate(self, path):
+ if self._selection_mode:
+ if path in self._selected:
+ self._selected.remove(path)
+ else:
+ self._selected.add(path)
+ self._rebuild()
+ else:
+ self._open(path)
+
+ def _select(self, check, path):
+ if check.get_active():
+ self._selected.add(path)
+ else:
+ self._selected.discard(path)
+ self._update_count()
+
+ def _on_select_all(self, _button):
+ visible = {entry.path for entry in self._entries[:self._limit]}
+ self._selected = set() if visible <= self._selected else visible
+ self._rebuild()
+
+ def _update_count(self):
+ count = len(self._selected)
+ if self._selection_mode:
+ self._status.set_label(ngettext("%d item selected", "%d items selected", count) % count)
+ elif self._entries:
+ self._status.set_label(_("Showing %(shown)d of %(total)d items") %
+ {"shown": min(self._limit, len(self._entries)), "total": len(self._entries)})
+ else:
+ self._status.set_label(_("No media yet"))
+
+ def _open(self, path):
+ launcher = Gtk.FileLauncher.new(Gio.File.new_for_path(path))
+ def done(launcher, result):
+ try:
+ launcher.launch_finish(result)
+ except GLib.Error:
+ self._error(_("Could not open the file or folder."))
+ launcher.launch(self.get_root(), None, done)
+
+ def _confirm_trash(self, paths):
+ allowed = {entry.path for entry in self._entries}
+ paths = [path for path in paths if path in allowed]
+ if not paths:
+ return
+ count = len(paths)
+ dialog = Adw.AlertDialog(heading=ngettext("Move %d item to Trash?", "Move %d items to Trash?", count) % count,
+ body=_("You can restore these files from the system Trash. Files that cannot be trashed will be kept."))
+ dialog.add_response("cancel", _("Cancel"))
+ dialog.add_response("trash", _("Move to Trash"))
+ dialog.set_close_response("cancel")
+ dialog.set_default_response("cancel")
+ dialog.set_response_appearance("trash", Adw.ResponseAppearance.DESTRUCTIVE)
+ def response(_dialog, choice):
+ if choice != "trash":
+ return
+ def move():
+ failed = []
+ for path in paths:
+ try:
+ Gio.File.new_for_path(path).trash(None)
+ except GLib.Error:
+ failed.append(path)
+ return failed
+ def done(failed):
+ self._selected.difference_update(set(paths) - set(failed))
+ self.refresh()
+ if failed:
+ self._error(ngettext("%d file could not be moved to Trash.", "%d files could not be moved to Trash.", len(failed)) % len(failed))
+ run_async(move, on_success=done, on_error=lambda exc: self._error(_("Could not move files to Trash.")))
+ dialog.connect("response", response)
+ dialog.present(self.get_root())
+
+ def _error(self, message):
+ if self._closed:
+ return
+ self._status.set_label(message)
+ root = self.get_root()
+ if root and hasattr(root, "_show_notification"):
+ root._show_notification(message, "error", 0)
+
+ def cleanup(self):
+ self._closed = True
+ self._generation += 1
+ self._scope["alive"] = False
+ self._scope.get("pending", deque()).clear()
diff --git a/usr/share/biglinux/bigcam/ui/photo_gallery.py b/usr/share/biglinux/bigcam/ui/photo_gallery.py
index 3bc97dd..2033434 100644
--- a/usr/share/biglinux/bigcam/ui/photo_gallery.py
+++ b/usr/share/biglinux/bigcam/ui/photo_gallery.py
@@ -1,439 +1,8 @@
-"""Photo gallery – grid/list view of captured photos with selection support."""
-
-from __future__ import annotations
-
-import os
-import threading
-import time
-from concurrent.futures import ThreadPoolExecutor
-
-import gi
-
-gi.require_version("Gtk", "4.0")
-gi.require_version("Adw", "1")
-
-from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, GLib
-
+"""Photo media gallery."""
+from ui.media_gallery import MediaGallery
from utils import xdg
-from utils.i18n import _
-
-
-def _human_size(nbytes: int) -> str:
- for unit in ("B", "KB", "MB", "GB"):
- if nbytes < 1024:
- return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} {unit}"
- nbytes /= 1024
- return f"{nbytes:.1f} TB"
-
-
-def _human_date(timestamp: float) -> str:
- return time.strftime("%d/%m/%Y %H:%M", time.localtime(timestamp))
-
-
-class PhotoGallery(Gtk.Box):
- """Gallery of captured photo thumbnails with grid/list and bulk selection."""
-
- THUMB_SIZE = 160
- LIST_THUMB = 48
-
- def __init__(self) -> None:
- super().__init__(orientation=Gtk.Orientation.VERTICAL)
- self._photos_dir = xdg.photos_dir()
- self._selection_mode = False
- self._selected: set[str] = set()
- self._view = "grid"
- self._items: list[str] = []
- self._thumb_pool = ThreadPoolExecutor(max_workers=4, thread_name_prefix="thumb")
-
- # ── Header ───────────────────────────────────────────────────
- header = Gtk.Box(
- orientation=Gtk.Orientation.HORIZONTAL,
- spacing=8,
- margin_top=12,
- margin_start=12,
- margin_end=12,
- )
- title = Gtk.Label(label=_("Captured Photos"), hexpand=True, xalign=0)
- title.add_css_class("title-4")
- header.append(title)
-
- # View toggle (grid / list)
- self._grid_btn = Gtk.ToggleButton(icon_name="view-grid-symbolic")
- self._grid_btn.set_active(True)
- self._grid_btn.set_tooltip_text(_("Grid view"))
- self._list_btn = Gtk.ToggleButton(icon_name="view-list-symbolic")
- self._list_btn.set_group(self._grid_btn)
- self._list_btn.set_tooltip_text(_("List view"))
- self._grid_btn.connect("toggled", self._on_view_toggled, "grid")
- self._list_btn.connect("toggled", self._on_view_toggled, "list")
-
- view_box = Gtk.Box(spacing=0)
- view_box.add_css_class("linked")
- view_box.append(self._grid_btn)
- view_box.append(self._list_btn)
- header.append(view_box)
-
- # Select mode toggle
- self._select_btn = Gtk.ToggleButton(icon_name="object-select-symbolic")
- self._select_btn.set_tooltip_text(_("Select items"))
- self._select_btn.connect("toggled", self._on_select_toggled)
- header.append(self._select_btn)
-
- refresh_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic")
- refresh_btn.add_css_class("flat")
- refresh_btn.set_tooltip_text(_("Refresh"))
- refresh_btn.connect("clicked", lambda _b: self.refresh())
- header.append(refresh_btn)
-
- open_btn = Gtk.Button.new_from_icon_name("folder-open-symbolic")
- open_btn.add_css_class("flat")
- open_btn.set_tooltip_text(_("Open photos folder"))
- open_btn.connect("clicked", self._on_open_folder)
- header.append(open_btn)
-
- self.append(header)
-
- # ── Content stack (grid / list) ──────────────────────────────
- self._scroll = Gtk.ScrolledWindow(
- hscrollbar_policy=Gtk.PolicyType.NEVER,
- vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
- vexpand=True,
- )
- self._stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
-
- # Grid
- self._flowbox = Gtk.FlowBox(
- homogeneous=True,
- max_children_per_line=6,
- min_children_per_line=2,
- selection_mode=Gtk.SelectionMode.NONE,
- row_spacing=8,
- column_spacing=8,
- margin_top=8,
- margin_bottom=8,
- margin_start=12,
- margin_end=12,
- )
- self._stack.add_named(self._flowbox, "grid")
-
- # List
- self._listbox = Gtk.ListBox(
- selection_mode=Gtk.SelectionMode.NONE,
- margin_top=8,
- margin_bottom=8,
- margin_start=12,
- margin_end=12,
- )
- self._listbox.add_css_class("boxed-list")
- self._stack.add_named(self._listbox, "list")
-
- self._scroll.set_child(self._stack)
- self.append(self._scroll)
-
- # Empty state
- self._empty = Adw.StatusPage(
- icon_name="image-x-generic-symbolic",
- title=_("No photos yet"),
- description=_("Captured photos will appear here."),
- )
- self._empty.set_visible(False)
- self.append(self._empty)
-
- # ── Selection action bar ─────────────────────────────────────
- self._action_bar = Gtk.ActionBar()
- self._action_bar.set_visible(False)
-
- self._sel_label = Gtk.Label(label=_("0 selected"))
- self._action_bar.set_center_widget(self._sel_label)
-
- select_all_btn = Gtk.Button(label=_("Select All"))
- select_all_btn.connect("clicked", self._on_select_all)
- self._action_bar.pack_start(select_all_btn)
-
- del_sel_btn = Gtk.Button(label=_("Delete"))
- del_sel_btn.add_css_class("destructive-action")
- del_sel_btn.connect("clicked", self._on_delete_selected)
- self._action_bar.pack_end(del_sel_btn)
-
- self.append(self._action_bar)
-
- self.connect("map", self._on_mapped)
-
- # ── View / selection toggles ─────────────────────────────────────
-
- def _on_view_toggled(self, btn: Gtk.ToggleButton, mode: str) -> None:
- if not btn.get_active():
- return
- self._view = mode
- self.refresh()
-
- def _on_select_toggled(self, btn: Gtk.ToggleButton) -> None:
- self._selection_mode = btn.get_active()
- self._selected.clear()
- self._action_bar.set_visible(self._selection_mode)
- self._update_sel_label()
- self.refresh()
-
- def _update_sel_label(self) -> None:
- n = len(self._selected)
- self._sel_label.set_label(
- _("%d selected") % n if n else _("0 selected")
- )
-
- # ── Mapped / refresh ─────────────────────────────────────────────
-
- def _on_mapped(self, _widget: Gtk.Widget) -> None:
- self.refresh()
-
- def refresh(self) -> None:
- # Clear grid
- child = self._flowbox.get_first_child()
- while child:
- nxt = child.get_next_sibling()
- self._flowbox.remove(child)
- child = nxt
- # Clear list
- child = self._listbox.get_first_child()
- while child:
- nxt = child.get_next_sibling()
- self._listbox.remove(child)
- child = nxt
-
- self._items = self._list_photos()
- has = len(self._items) > 0
- self._empty.set_visible(not has)
- self._scroll.set_visible(has)
-
- self._stack.set_visible_child_name(self._view)
-
- for path in self._items[:100]:
- if self._view == "grid":
- w = self._make_grid_item(path)
- else:
- w = self._make_list_item(path)
- if w:
- if self._view == "grid":
- self._flowbox.append(w)
- else:
- self._listbox.append(w)
-
- def _list_photos(self) -> list[str]:
- if not os.path.isdir(self._photos_dir):
- return []
- files: list[str] = []
- for entry in sorted(
- os.scandir(self._photos_dir),
- key=lambda e: e.stat().st_mtime,
- reverse=True,
- ):
- if entry.is_file() and entry.name.lower().endswith(
- (".jpg", ".jpeg", ".png", ".webp")
- ):
- files.append(entry.path)
- return files
-
- # ── Grid item ────────────────────────────────────────────────────
-
- def _make_grid_item(self, path: str) -> Gtk.Widget | None:
- picture = Gtk.Picture()
- picture.set_content_fit(Gtk.ContentFit.COVER)
- picture.set_size_request(self.THUMB_SIZE, self.THUMB_SIZE)
- picture.add_css_class("card")
-
- # Load thumbnail asynchronously
- def _load():
- try:
- return GdkPixbuf.Pixbuf.new_from_file_at_scale(
- path, self.THUMB_SIZE, self.THUMB_SIZE, True
- )
- except Exception:
- return None
-
- def _on_loaded(pixbuf):
- if pixbuf:
- texture = Gdk.Texture.new_for_pixbuf(pixbuf)
- picture.set_paintable(texture)
-
- self._thumb_pool.submit(lambda: GLib.idle_add(_on_loaded, _load()))
-
- overlay = Gtk.Overlay()
-
- if self._selection_mode:
- check = Gtk.CheckButton(active=path in self._selected)
- check.set_halign(Gtk.Align.START)
- check.set_valign(Gtk.Align.START)
- check.set_margin_start(6)
- check.set_margin_top(6)
- check.add_css_class("osd")
- check.connect("toggled", self._on_check_toggled, path)
-
- btn = Gtk.Button()
- btn.set_child(picture)
- btn.add_css_class("flat")
- btn.connect("clicked", self._on_grid_check_click, check)
- overlay.set_child(btn)
- overlay.add_overlay(check)
- else:
- btn = Gtk.Button()
- btn.set_child(picture)
- btn.add_css_class("flat")
- btn.set_tooltip_text(os.path.basename(path))
- btn.connect("clicked", self._on_open_photo, path)
- overlay.set_child(btn)
-
- del_btn = Gtk.Button.new_from_icon_name("user-trash-symbolic")
- del_btn.add_css_class("osd")
- del_btn.add_css_class("circular")
- del_btn.add_css_class("delete-thumb-btn")
- del_btn.set_halign(Gtk.Align.END)
- del_btn.set_valign(Gtk.Align.START)
- del_btn.set_margin_end(4)
- del_btn.set_margin_top(4)
- del_btn.set_tooltip_text(_("Delete"))
- del_btn.connect("clicked", self._on_delete_clicked, path)
- overlay.add_overlay(del_btn)
-
- return overlay
-
- def _on_grid_check_click(self, _btn: Gtk.Button, check: Gtk.CheckButton) -> None:
- check.set_active(not check.get_active())
-
- # ── List item ────────────────────────────────────────────────────
-
- def _make_list_item(self, path: str) -> Gtk.Widget | None:
- name = os.path.basename(path)
- try:
- st = os.stat(path)
- size = _human_size(st.st_size)
- date = _human_date(st.st_mtime)
- except OSError:
- size = ""
- date = ""
-
- row = Adw.ActionRow(title=name, subtitle=f"{size} · {date}")
- row.set_activatable(not self._selection_mode)
-
- # Small thumbnail prefix (loaded asynchronously)
- frame = Gtk.Frame()
- pic = Gtk.Picture()
- pic.set_content_fit(Gtk.ContentFit.COVER)
- pic.set_size_request(self.LIST_THUMB, self.LIST_THUMB)
- frame.set_child(pic)
- row.add_prefix(frame)
-
- def _load_list_thumb():
- try:
- return GdkPixbuf.Pixbuf.new_from_file_at_scale(
- path, self.LIST_THUMB, self.LIST_THUMB, True
- )
- except Exception:
- return None
-
- def _on_list_thumb(pixbuf):
- if pixbuf:
- texture = Gdk.Texture.new_for_pixbuf(pixbuf)
- pic.set_paintable(texture)
- else:
- icon = Gtk.Image.new_from_icon_name("image-x-generic-symbolic")
- icon.set_pixel_size(self.LIST_THUMB)
- frame.set_child(icon)
-
- self._thumb_pool.submit(lambda: GLib.idle_add(_on_list_thumb, _load_list_thumb()))
-
- if self._selection_mode:
- check = Gtk.CheckButton(active=path in self._selected)
- check.set_valign(Gtk.Align.CENTER)
- check.connect("toggled", self._on_check_toggled, path)
- row.add_suffix(check)
- row.connect("activated", lambda _r, c=check: c.set_active(not c.get_active()))
- else:
- row.connect("activated", self._on_row_activated, path)
- del_btn = Gtk.Button.new_from_icon_name("user-trash-symbolic")
- del_btn.add_css_class("flat")
- del_btn.set_valign(Gtk.Align.CENTER)
- del_btn.set_tooltip_text(_("Delete"))
- del_btn.connect("clicked", self._on_delete_clicked, path)
- row.add_suffix(del_btn)
-
- return row
-
- def _on_row_activated(self, _row: Adw.ActionRow, path: str) -> None:
- self._on_open_photo(None, path)
-
- # ── Selection ────────────────────────────────────────────────────
-
- def _on_check_toggled(self, check: Gtk.CheckButton, path: str) -> None:
- if check.get_active():
- self._selected.add(path)
- else:
- self._selected.discard(path)
- self._update_sel_label()
-
- def _on_select_all(self, _btn: Gtk.Button) -> None:
- all_selected = len(self._selected) == len(self._items[:100])
- self._selected = set() if all_selected else set(self._items[:100])
- self._update_sel_label()
- self.refresh()
-
- def _on_delete_selected(self, _btn: Gtk.Button) -> None:
- if not self._selected:
- return
- n = len(self._selected)
- dialog = Adw.AlertDialog(
- heading=_("Delete %d photos?") % n,
- body=_("These photos will be permanently deleted."),
- )
- dialog.add_response("cancel", _("Cancel"))
- dialog.add_response("delete", _("Delete"))
- dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
- dialog.set_default_response("cancel")
- dialog.set_close_response("cancel")
- dialog.connect("response", self._on_bulk_delete_response)
- dialog.present(self.get_root())
-
- def _on_bulk_delete_response(self, _dialog: Adw.AlertDialog, response: str) -> None:
- if response != "delete":
- return
- for p in list(self._selected):
- try:
- os.remove(p)
- except OSError:
- pass
- self._selected.clear()
- self._update_sel_label()
- self.refresh()
-
- # ── Actions ──────────────────────────────────────────────────────
-
- def _on_open_photo(self, _btn: Gtk.Button | None, path: str) -> None:
- uri = GLib.filename_to_uri(path)
- Gtk.show_uri(self.get_root(), uri, Gdk.CURRENT_TIME)
-
- def _on_open_folder(self, _btn: Gtk.Button) -> None:
- os.makedirs(self._photos_dir, exist_ok=True)
- uri = GLib.filename_to_uri(self._photos_dir)
- Gtk.show_uri(self.get_root(), uri, Gdk.CURRENT_TIME)
- def _on_delete_clicked(self, _btn: Gtk.Button, path: str) -> None:
- dialog = Adw.AlertDialog(
- heading=_("Delete photo?"),
- body=_('"%s" will be permanently deleted.') % os.path.basename(path),
- )
- dialog.add_response("cancel", _("Cancel"))
- dialog.add_response("delete", _("Delete"))
- dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
- dialog.set_default_response("cancel")
- dialog.set_close_response("cancel")
- dialog.connect("response", self._on_delete_response, path)
- dialog.present(self.get_root())
- def _on_delete_response(
- self, _dialog: Adw.AlertDialog, response: str, path: str
- ) -> None:
- if response != "delete":
- return
- try:
- os.remove(path)
- except OSError:
- pass
- self.refresh()
+class PhotoGallery(MediaGallery):
+ def __init__(self):
+ super().__init__("photo", xdg.photos_dir())
diff --git a/usr/share/biglinux/bigcam/ui/preview_area.py b/usr/share/biglinux/bigcam/ui/preview_area.py
index c93e9cb..5cdce72 100644
--- a/usr/share/biglinux/bigcam/ui/preview_area.py
+++ b/usr/share/biglinux/bigcam/ui/preview_area.py
@@ -11,7 +11,7 @@
from core.audio_monitor import AudioMonitor
from core.stream_engine import StreamEngine
-from utils.i18n import _
+from utils.i18n import _, ngettext
class MirroredPicture(Gtk.Picture):
@@ -68,7 +68,7 @@ def __init__(self, stream_engine: StreamEngine) -> None:
# -- video picture ---------------------------------------------------
self._picture = MirroredPicture()
- self._picture.set_content_fit(Gtk.ContentFit.COVER)
+ self._picture.set_content_fit(Gtk.ContentFit.CONTAIN)
self._picture.set_hexpand(True)
self._picture.set_vexpand(True)
self._picture.set_halign(Gtk.Align.FILL)
@@ -307,6 +307,10 @@ def _build_audio_overlay(self) -> Gtk.Box:
)
self._vol_sliders_box.set_visible(False)
self._vol_sliders_box.set_halign(Gtk.Align.START)
+ self._volume_toggle = Gtk.ToggleButton(label=_("Volume"))
+ self._volume_toggle.update_property([Gtk.AccessibleProperty.LABEL], [_("Show volume controls")])
+ self._volume_toggle.connect("toggled", lambda button: self._vol_sliders_box.set_visible(button.get_active()))
+ top_row.append(self._volume_toggle)
outer.append(self._vol_sliders_box)
# Track per-source slider widgets: {source_name: Gtk.Scale}
@@ -350,7 +354,7 @@ def _on_sources_changed(self, mon: AudioMonitor) -> None:
self._vol_sliders_box.remove(child)
self._source_scales.clear()
self._source_scale_handlers.clear()
- self._vol_sliders_box.set_visible(False)
+ self._vol_sliders_box.set_visible(self._volume_toggle.get_active())
sources = mon.sources
if not sources:
@@ -370,7 +374,7 @@ def _on_sources_changed(self, mon: AudioMonitor) -> None:
check.set_tooltip_text(label)
check.update_property(
[Gtk.AccessibleProperty.LABEL],
- [f"{label} – audio {idx}"],
+ [_("%(source)s — audio %(index)d") % {"source": label, "index": idx}],
)
check.connect("toggled", self._on_audio_check_toggled, src_name)
item_box.append(check)
@@ -401,7 +405,7 @@ def _on_sources_changed(self, mon: AudioMonitor) -> None:
vol_scale.set_halign(Gtk.Align.CENTER)
vol_scale.update_property(
[Gtk.AccessibleProperty.LABEL],
- [f"{label} – volume"],
+ [_("%s — volume") % label],
)
handler_id = vol_scale.connect(
"value-changed", self._on_source_vol_changed, src_name
@@ -428,14 +432,12 @@ def _on_sources_changed(self, mon: AudioMonitor) -> None:
self._audio_box.set_visible(True)
self._audio_rebuilding = False
- def _on_audio_hover_enter(self, *_args) -> None:
- """Show volume sliders when mouse enters the audio overlay."""
- if self._source_scales:
- self._vol_sliders_box.set_visible(True)
+ def _on_audio_hover_enter(self, *_args):
+ # Explicit toggle works with keyboard, touch and pointer alike.
+ pass
- def _on_audio_hover_leave(self, *_args) -> None:
- """Hide volume sliders when mouse leaves the audio overlay."""
- self._vol_sliders_box.set_visible(False)
+ def _on_audio_hover_leave(self, *_args):
+ pass
def _on_audio_check_toggled(
self, check: Gtk.CheckButton, source_name: str
@@ -458,6 +460,7 @@ def _update_mute_icon(self, muted: bool) -> None:
icon = "audio-volume-high-symbolic"
self._mute_btn.set_tooltip_text(_("Mute"))
self._mute_btn.set_icon_name(icon)
+ self._mute_btn.update_property([Gtk.AccessibleProperty.LABEL], [_("Unmute") if muted else _("Mute")])
def _on_mute_clicked(self, _btn: Gtk.Button) -> None:
if self._audio_monitor:
@@ -517,7 +520,7 @@ def _update_fps(self) -> bool:
if fps > 0:
self._fps_label.set_text(f"{fps:.0f} FPS")
else:
- self._fps_label.set_text("⏵ Live")
+ self._fps_label.set_text(_("Live"))
return True
self._fps_label.set_visible(False)
self._fps_timer = None
@@ -658,6 +661,8 @@ def _show_retry(self) -> bool:
)
)
self._status.set_icon_name("dialog-warning-symbolic")
+ self._status.set_child(self._retry_btn)
+ self._stop_progress_pulse()
self._retry_btn.set_visible(True)
self._stack.set_visible_child_name("status")
self._last_error = ""
@@ -669,6 +674,7 @@ def _cancel_retry_timer(self) -> None:
self._retry_timer = None
def set_recording_state(self, recording: bool) -> None:
+ self._record_btn.update_property([Gtk.AccessibleProperty.LABEL], [_("Stop recording") if recording else _("Record video")])
self._is_recording = recording
if recording:
self._record_btn.add_css_class("recording")
@@ -722,9 +728,12 @@ def start_countdown(self, seconds: int, callback) -> None:
self._countdown_label.set_label(str(seconds))
self._countdown_label.update_property(
[Gtk.AccessibleProperty.LABEL],
- [_("{n} seconds remaining").format(n=seconds)],
+ [ngettext("{n} second remaining", "{n} seconds remaining", seconds).format(n=seconds)],
)
self._countdown_label.set_visible(True)
+ if hasattr(self._countdown_label, "announce"):
+ self._countdown_label.announce(ngettext("%d second remaining", "%d seconds remaining", seconds) % seconds,
+ Gtk.AccessibleAnnouncementPriority.MEDIUM)
self._countdown_timer_id = GLib.timeout_add(1000, self._tick_countdown)
def _tick_countdown(self) -> bool:
@@ -733,7 +742,7 @@ def _tick_countdown(self) -> bool:
self._countdown_label.set_label(str(self._countdown_remaining))
self._countdown_label.update_property(
[Gtk.AccessibleProperty.LABEL],
- [_("{n} seconds remaining").format(n=self._countdown_remaining)],
+ [ngettext("{n} second remaining", "{n} seconds remaining", self._countdown_remaining).format(n=self._countdown_remaining)],
)
return True
self._countdown_label.set_visible(False)
@@ -749,3 +758,13 @@ def _cancel_countdown(self) -> None:
self._countdown_timer_id = None
self._countdown_label.set_visible(False)
self._countdown_callback = None
+
+ def cancel_countdown(self):
+ self._cancel_countdown()
+
+ def cleanup(self):
+ self._cancel_countdown()
+ self._cancel_retry_timer()
+ self._stop_fps_timer()
+ self._stop_progress_pulse()
+ self.dismiss()
diff --git a/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py b/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py
index 41c2379..7d7079e 100644
--- a/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py
+++ b/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py
@@ -11,6 +11,7 @@
from __future__ import annotations
import logging
+from collections.abc import Callable
from typing import TYPE_CHECKING
import gi
diff --git a/usr/share/biglinux/bigcam/ui/settings_page.py b/usr/share/biglinux/bigcam/ui/settings_page.py
index 5f6d4cf..ce40103 100644
--- a/usr/share/biglinux/bigcam/ui/settings_page.py
+++ b/usr/share/biglinux/bigcam/ui/settings_page.py
@@ -62,6 +62,9 @@ def __init__(self, settings: SettingsManager, stream_engine=None, camera_manager
hscrollbar_policy=Gtk.PolicyType.NEVER,
vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
)
+ self._closed = False
+ self._debounce_sources = {}
+ self._scan_generation = 0
self._settings = settings
self._engine = stream_engine
self._camera_manager = camera_manager
@@ -140,11 +143,11 @@ def _build_general(self, content: Gtk.Box) -> None:
self._theme_row = Adw.ComboRow(title=_("Theme"))
self._theme_row.add_prefix(Gtk.Image.new_from_icon_name("preferences-desktop-appearance-symbolic"))
theme_model = Gtk.StringList()
- for t in (_("Light"), _("Dark")):
+ for t in (_("System"), _("Light"), _("Dark")):
theme_model.append(t)
self._theme_row.set_model(theme_model)
- theme_idx = {"light": 0, "dark": 1}.get(
- self._settings.get("theme"), 1
+ theme_idx = {"system": 0, "light": 1, "dark": 2}.get(
+ self._settings.get("theme"), 0
)
self._theme_row.set_selected(theme_idx)
self._theme_row.update_property(
@@ -229,6 +232,13 @@ def _build_general(self, content: Gtk.Box) -> None:
reset_warnings_row.set_activatable_widget(reset_btn)
general.add(reset_warnings_row)
+ for key, title, description in [
+ ("reduce-motion", _("Reduce motion and flashes"), _("Avoid capture flashes and decorative animations.")),
+ ("auto-hide-controls", _("Automatically hide camera controls"), _("Controls remain visible while using keyboard focus."))]:
+ row = Adw.SwitchRow(title=title, subtitle=description)
+ row.set_active(self._settings.get(key))
+ row.connect("notify::active", lambda row, _spec, name=key: self._settings.set(name, row.get_active()))
+ general.add(row)
content.append(general)
def _build_preview(self, content: Gtk.Box) -> None:
@@ -281,7 +291,8 @@ def _build_preview(self, content: Gtk.Box) -> None:
self._window_opacity_scale.set_value(self._settings.get("window-opacity"))
self._window_opacity_scale.set_hexpand(True)
self._window_opacity_scale.set_valign(Gtk.Align.CENTER)
- self._window_opacity_scale.set_size_request(180, -1)
+ self._window_opacity_scale.set_size_request(120, -1)
+ self._window_opacity_scale.update_property([Gtk.AccessibleProperty.LABEL], [_("Background transparency")])
self._window_opacity_scale.connect("value-changed", self._on_window_opacity)
window_opacity_row.add_suffix(self._window_opacity_scale)
preview.add(window_opacity_row)
@@ -298,7 +309,8 @@ def _build_preview(self, content: Gtk.Box) -> None:
self._opacity_scale.set_value(self._settings.get("overlay-opacity"))
self._opacity_scale.set_hexpand(True)
self._opacity_scale.set_valign(Gtk.Align.CENTER)
- self._opacity_scale.set_size_request(180, -1)
+ self._opacity_scale.set_size_request(120, -1)
+ self._opacity_scale.update_property([Gtk.AccessibleProperty.LABEL], [_("Overlay opacity")])
self._opacity_scale.connect("value-changed", self._on_overlay_opacity)
opacity_row.add_suffix(self._opacity_scale)
preview.add(opacity_row)
@@ -315,7 +327,8 @@ def _build_preview(self, content: Gtk.Box) -> None:
self._controls_opacity_scale.set_value(self._settings.get("controls-opacity"))
self._controls_opacity_scale.set_hexpand(True)
self._controls_opacity_scale.set_valign(Gtk.Align.CENTER)
- self._controls_opacity_scale.set_size_request(180, -1)
+ self._controls_opacity_scale.set_size_request(120, -1)
+ self._controls_opacity_scale.update_property([Gtk.AccessibleProperty.LABEL], [_("Controls opacity")])
self._controls_opacity_scale.connect("value-changed", self._on_controls_opacity)
controls_opacity_row.add_suffix(self._controls_opacity_scale)
preview.add(controls_opacity_row)
@@ -418,6 +431,14 @@ def _build_recording(self, content: Gtk.Box) -> None:
def _on_vcodec(row, _pspec):
idx = row.get_selected()
+ if idx >= len(_vcodec_keys):
+ return
+ if self._settings.get("recording-container") == "webm" and _vcodec_keys[idx] != "vp9":
+ row.set_selected(_vcodec_map["vp9"])
+ return
+ if self._settings.get("recording-container") == "mp4" and _vcodec_keys[idx] == "mjpeg":
+ row.set_selected(_vcodec_map["h264"])
+ return
self._settings.set("recording-video-codec", _vcodec_keys[idx])
self.emit("recording-config-changed")
@@ -437,6 +458,14 @@ def _on_vcodec(row, _pspec):
def _on_acodec(row, _pspec):
idx = row.get_selected()
+ if idx >= len(_acodec_keys):
+ return
+ if self._settings.get("recording-container") == "webm" and _acodec_keys[idx] not in {"opus", "vorbis"}:
+ row.set_selected(_acodec_map["opus"])
+ return
+ if self._settings.get("recording-container") == "mp4" and _acodec_keys[idx] == "vorbis":
+ row.set_selected(_acodec_map["aac"])
+ return
self._settings.set("recording-audio-codec", _acodec_keys[idx])
self.emit("recording-config-changed")
@@ -456,6 +485,8 @@ def _on_acodec(row, _pspec):
def _on_container(row, _pspec):
idx = row.get_selected()
+ if idx >= len(_container_keys):
+ return
container = _container_keys[idx]
self._settings.set("recording-container", container)
# Auto-correct codecs incompatible with the chosen container
@@ -466,7 +497,7 @@ def _on_container(row, _pspec):
self._acodec_row.set_selected(_acodec_map["opus"])
elif container == "mp4":
vcodec = _vcodec_keys[self._vcodec_row.get_selected()]
- if vcodec in ("vp9", "mjpeg"):
+ if vcodec == "mjpeg":
self._vcodec_row.set_selected(_vcodec_map["h264"])
if _acodec_keys[self._acodec_row.get_selected()] == "vorbis":
self._acodec_row.set_selected(_acodec_map["aac"])
@@ -541,7 +572,6 @@ def _build_virtual_camera(self, content: Gtk.Box) -> None:
self._vc_toggle_row.connect("notify::active", self._on_vc_toggle)
vc_group.add(self._vc_toggle_row)
- content.append(vc_group)
# Per-device virtual camera group
self._vc_devices_group = Adw.PreferencesGroup(
@@ -593,10 +623,11 @@ def _build_virtual_camera(self, content: Gtk.Box) -> None:
def _on_theme(self, row: Adw.ComboRow, _pspec) -> None:
idx = row.get_selected()
- value = {0: "light", 1: "dark"}.get(idx, "dark")
+ value = {0: "system", 1: "light", 2: "dark"}.get(idx, "system")
self._settings.set("theme", value)
style_manager = Adw.StyleManager.get_default()
scheme_map = {
+ "system": Adw.ColorScheme.DEFAULT,
"light": Adw.ColorScheme.FORCE_LIGHT,
"dark": Adw.ColorScheme.FORCE_DARK,
}
@@ -619,7 +650,13 @@ def _on_show_fps(self, row: Adw.SwitchRow, _pspec) -> None:
def _on_hotplug(self, row: Adw.SwitchRow, _pspec) -> None:
- self._settings.set("hotplug_enabled", row.get_active())
+ enabled = row.get_active()
+ self._settings.set("hotplug_enabled", enabled)
+ if self._camera_manager:
+ if enabled:
+ self._camera_manager.start_hotplug()
+ else:
+ self._camera_manager.stop_hotplug()
def _on_help_tooltips(self, row: Adw.SwitchRow, _pspec) -> None:
active = row.get_active()
@@ -699,20 +736,20 @@ def _on_grid_overlay(self, row: Adw.SwitchRow, _pspec) -> None:
self._settings.set("grid_overlay", active)
self.emit("grid-overlay-changed", active)
- def _on_overlay_opacity(self, scale: Gtk.Scale) -> None:
+ def _on_overlay_opacity(self, scale):
value = int(scale.get_value())
- self._settings.set("overlay-opacity", value)
self.emit("overlay-opacity-changed", value)
+ self._save_later("overlay-opacity", value)
- def _on_window_opacity(self, scale: Gtk.Scale) -> None:
+ def _on_window_opacity(self, scale):
value = int(scale.get_value())
- self._settings.set("window-opacity", value)
self.emit("window-opacity-changed", value)
+ self._save_later("window-opacity", value)
- def _on_controls_opacity(self, scale: Gtk.Scale) -> None:
+ def _on_controls_opacity(self, scale):
value = int(scale.get_value())
- self._settings.set("controls-opacity", value)
self.emit("controls-opacity-changed", value)
+ self._save_later("controls-opacity", value)
@staticmethod
def _open_directory(path: str) -> None:
@@ -740,7 +777,7 @@ def _make_group_reset_button(
return btn
def _on_reset_general(self, _btn: Gtk.Button) -> None:
- self._theme_row.set_selected(1) # dark
+ self._theme_row.set_selected(0) # system
self._hotplug_row.set_active(True)
self._help_tooltips_row.set_active(True)
self._resource_row.set_active(True)
@@ -768,9 +805,11 @@ def _on_reset_recording(self, _btn: Gtk.Button) -> None:
# -- QR Code handlers ----------------------------------------------------
def _on_qr_toggled(self, row: Adw.SwitchRow, _pspec) -> None:
+ self._scan_generation += 1
self._qr_active = row.get_active()
if self._qr_active:
self._init_qr_detector()
+ self._engine.set_qr_scanning(True)
self._qr_timer_id = GLib.timeout_add(150, self._scan_qr)
else:
if self._qr_timer_id:
@@ -778,6 +817,7 @@ def _on_qr_toggled(self, row: Adw.SwitchRow, _pspec) -> None:
self._qr_timer_id = None
self._last_qr_text = ""
self._engine.set_overlay_rects([])
+ self._engine.set_qr_scanning(False)
def _init_qr_detector(self) -> None:
if self._wechat_qr is not None or self._qr_detector is not None:
@@ -832,9 +872,9 @@ def _scan_qr(self) -> bool:
self._qr_scanning = True
import threading
- threading.Thread(
- target=self._scan_qr_worker, args=(frame.copy(),), daemon=True
- ).start()
+ from utils.async_worker import run_async
+ self._worker_generation = (self._scan_generation, self._engine.current_camera)
+ run_async(self._scan_qr_worker, args=(frame.copy(),))
return True
def _scan_qr_worker(self, frame) -> None:
@@ -891,10 +931,13 @@ def _scan_qr_worker(self, frame) -> None:
def _scan_qr_done(self, data: str, rects: list) -> bool:
self._qr_scanning = False
+ if self._closed or not self._qr_active or getattr(self, "_worker_generation", None) != (self._scan_generation, self._engine.current_camera):
+ return GLib.SOURCE_REMOVE
self._engine.set_overlay_rects(rects)
- if data and data != self._last_qr_text:
+ if data and data != self._last_qr_text and not getattr(self, "_qr_dialog_open", False):
self._last_qr_text = data
self.emit("qr-detected", data)
+ self._qr_dialog_open = True
qr_result = self._parse_qr(data)
dialog = self._QrDialog(qr_result)
root = self.get_root()
@@ -905,6 +948,7 @@ def _scan_qr_done(self, data: str, rects: list) -> bool:
return False
def _on_qr_dialog_closed(self, dialog) -> bool:
+ self._qr_dialog_open = False
self._last_qr_text = ""
dialog.destroy()
return True
@@ -1017,3 +1061,28 @@ def set_vc_toggle_active(self, active: bool) -> None:
self._vc_toggle_row.set_active(active)
self._vc_updating = False
self._refresh_vc_status()
+
+ def cleanup(self):
+ self._closed = True
+ self._scan_generation += 1
+ self._settings.update(getattr(self, "_pending_settings", {}))
+ for timer in getattr(self, "_debounce_sources", {}).values():
+ GLib.source_remove(timer)
+ getattr(self, "_debounce_sources", {}).clear()
+ for name in ("_qr_timer_id",):
+ timer = getattr(self, name, None)
+ if timer:
+ GLib.source_remove(timer)
+ setattr(self, name, None)
+
+ def _save_later(self, key, value):
+ timer = self._debounce_sources.pop(key, None)
+ if timer:
+ GLib.source_remove(timer)
+ self._pending_settings = getattr(self, "_pending_settings", {})
+ self._pending_settings[key] = value
+ def save():
+ self._debounce_sources.pop(key, None)
+ self._settings.set(key, self._pending_settings.pop(key))
+ return GLib.SOURCE_REMOVE
+ self._debounce_sources[key] = GLib.timeout_add(200, save)
diff --git a/usr/share/biglinux/bigcam/ui/video_gallery.py b/usr/share/biglinux/bigcam/ui/video_gallery.py
index ad95a3c..7c7b3bb 100644
--- a/usr/share/biglinux/bigcam/ui/video_gallery.py
+++ b/usr/share/biglinux/bigcam/ui/video_gallery.py
@@ -1,546 +1,8 @@
-"""Video gallery – grid/list view of recorded videos with selection support."""
-
-from __future__ import annotations
-
-import logging
-import os
-import subprocess
-import time
-
-import gi
-
-gi.require_version("Gtk", "4.0")
-gi.require_version("Adw", "1")
-
-from gi.repository import Adw, Gtk, Gdk, GdkPixbuf, GLib
-
+"""Video media gallery."""
+from ui.media_gallery import MediaGallery
from utils import xdg
-from utils.async_worker import run_async
-from utils.i18n import _
-
-log = logging.getLogger(__name__)
-
-
-def _human_size(nbytes: int) -> str:
- for unit in ("B", "KB", "MB", "GB"):
- if nbytes < 1024:
- return f"{nbytes:.1f} {unit}" if unit != "B" else f"{nbytes} {unit}"
- nbytes /= 1024
- return f"{nbytes:.1f} TB"
-
-
-def _human_date(timestamp: float) -> str:
- return time.strftime("%d/%m/%Y %H:%M", time.localtime(timestamp))
-
-
-class _VideoMeta:
- __slots__ = ("path", "name", "size", "mtime", "duration", "thumb_path")
-
- def __init__(self, path: str) -> None:
- self.path = path
- self.name = os.path.basename(path)
- try:
- st = os.stat(path)
- self.size = st.st_size
- self.mtime = st.st_mtime
- except OSError:
- self.size = 0
- self.mtime = 0.0
- self.duration: str | None = None
- self.thumb_path: str | None = None
-
-
-class VideoGallery(Gtk.Box):
- """Gallery of recorded video thumbnails with grid/list and bulk selection."""
-
- THUMB_SIZE = 160
- LIST_THUMB = 48
-
- def __init__(self) -> None:
- super().__init__(orientation=Gtk.Orientation.VERTICAL)
- self._videos_dir = xdg.videos_dir()
- self._selection_mode = False
- self._selected: set[str] = set()
- self._view = "grid"
- self._metas: list[_VideoMeta] = []
-
- # ── Header ───────────────────────────────────────────────────
- header = Gtk.Box(
- orientation=Gtk.Orientation.HORIZONTAL,
- spacing=8,
- margin_top=12,
- margin_start=12,
- margin_end=12,
- )
- title = Gtk.Label(label=_("Recorded Videos"), hexpand=True, xalign=0)
- title.add_css_class("title-4")
- header.append(title)
-
- # View toggle (grid / list)
- self._grid_btn = Gtk.ToggleButton(icon_name="view-grid-symbolic")
- self._grid_btn.set_active(True)
- self._grid_btn.set_tooltip_text(_("Grid view"))
- self._list_btn = Gtk.ToggleButton(icon_name="view-list-symbolic")
- self._list_btn.set_group(self._grid_btn)
- self._list_btn.set_tooltip_text(_("List view"))
- self._grid_btn.connect("toggled", self._on_view_toggled, "grid")
- self._list_btn.connect("toggled", self._on_view_toggled, "list")
-
- view_box = Gtk.Box(spacing=0)
- view_box.add_css_class("linked")
- view_box.append(self._grid_btn)
- view_box.append(self._list_btn)
- header.append(view_box)
-
- # Select mode toggle
- self._select_btn = Gtk.ToggleButton(icon_name="object-select-symbolic")
- self._select_btn.set_tooltip_text(_("Select items"))
- self._select_btn.connect("toggled", self._on_select_toggled)
- header.append(self._select_btn)
-
- refresh_btn = Gtk.Button.new_from_icon_name("view-refresh-symbolic")
- refresh_btn.add_css_class("flat")
- refresh_btn.set_tooltip_text(_("Refresh"))
- refresh_btn.connect("clicked", lambda _b: self.refresh())
- header.append(refresh_btn)
-
- open_btn = Gtk.Button.new_from_icon_name("folder-open-symbolic")
- open_btn.add_css_class("flat")
- open_btn.set_tooltip_text(_("Open videos folder"))
- open_btn.connect("clicked", self._on_open_folder)
- header.append(open_btn)
-
- self.append(header)
-
- # ── Content stack (grid / list) ──────────────────────────────
- self._scroll = Gtk.ScrolledWindow(
- hscrollbar_policy=Gtk.PolicyType.NEVER,
- vscrollbar_policy=Gtk.PolicyType.AUTOMATIC,
- vexpand=True,
- )
- self._stack = Gtk.Stack(transition_type=Gtk.StackTransitionType.CROSSFADE)
-
- # Grid
- self._flowbox = Gtk.FlowBox(
- homogeneous=True,
- max_children_per_line=6,
- min_children_per_line=2,
- selection_mode=Gtk.SelectionMode.NONE,
- row_spacing=8,
- column_spacing=8,
- margin_top=8,
- margin_bottom=8,
- margin_start=12,
- margin_end=12,
- )
- self._stack.add_named(self._flowbox, "grid")
-
- # List
- self._listbox = Gtk.ListBox(
- selection_mode=Gtk.SelectionMode.NONE,
- margin_top=8,
- margin_bottom=8,
- margin_start=12,
- margin_end=12,
- )
- self._listbox.add_css_class("boxed-list")
- self._stack.add_named(self._listbox, "list")
-
- self._scroll.set_child(self._stack)
- self.append(self._scroll)
-
- # Empty state
- self._empty = Adw.StatusPage(
- icon_name="video-display-symbolic",
- title=_("No videos yet"),
- description=_("Recorded videos will appear here."),
- )
- self._empty.set_visible(False)
- self.append(self._empty)
-
- # ── Selection action bar ─────────────────────────────────────
- self._action_bar = Gtk.ActionBar()
- self._action_bar.set_visible(False)
-
- self._sel_label = Gtk.Label(label=_("0 selected"))
- self._action_bar.set_center_widget(self._sel_label)
-
- select_all_btn = Gtk.Button(label=_("Select All"))
- select_all_btn.connect("clicked", self._on_select_all)
- self._action_bar.pack_start(select_all_btn)
-
- del_sel_btn = Gtk.Button(label=_("Delete"))
- del_sel_btn.add_css_class("destructive-action")
- del_sel_btn.connect("clicked", self._on_delete_selected)
- self._action_bar.pack_end(del_sel_btn)
-
- self.append(self._action_bar)
-
- self.connect("map", self._on_mapped)
-
- # ── View / selection toggles ─────────────────────────────────────
-
- def _on_view_toggled(self, btn: Gtk.ToggleButton, mode: str) -> None:
- if not btn.get_active():
- return
- self._view = mode
- self._rebuild_from_cache()
-
- def _on_select_toggled(self, btn: Gtk.ToggleButton) -> None:
- self._selection_mode = btn.get_active()
- self._selected.clear()
- self._action_bar.set_visible(self._selection_mode)
- self._update_sel_label()
- self._rebuild_from_cache()
-
- def _update_sel_label(self) -> None:
- n = len(self._selected)
- self._sel_label.set_label(
- _("%d selected") % n if n else _("0 selected")
- )
-
- # ── Mapped / refresh ─────────────────────────────────────────────
-
- def _on_mapped(self, _widget: Gtk.Widget) -> None:
- self.refresh()
-
- def refresh(self) -> None:
- self._clear_containers()
- videos = self._list_videos()
- self._empty.set_visible(len(videos) == 0)
- self._scroll.set_visible(len(videos) > 0)
- if not videos:
- self._metas = []
- return
-
- batch = videos[:100]
-
- def _prepare() -> list[_VideoMeta]:
- results = []
- for path in batch:
- m = _VideoMeta(path)
- m.thumb_path = self._get_thumb_path(path)
- if m.thumb_path and not os.path.isfile(m.thumb_path):
- self._generate_thumb_file(path, m.thumb_path)
- m.duration = self._get_duration(path)
- results.append(m)
- return results
-
- def _done(metas: list[_VideoMeta]) -> None:
- self._metas = metas
- self._rebuild_from_cache()
-
- run_async(_prepare, on_success=_done)
-
- def _rebuild_from_cache(self) -> None:
- self._clear_containers()
- self._stack.set_visible_child_name(self._view)
- self._empty.set_visible(len(self._metas) == 0)
- self._scroll.set_visible(len(self._metas) > 0)
-
- for m in self._metas:
- if self._view == "grid":
- w = self._make_grid_item(m)
- else:
- w = self._make_list_item(m)
- if w:
- if self._view == "grid":
- self._flowbox.append(w)
- else:
- self._listbox.append(w)
-
- def _clear_containers(self) -> None:
- for container in (self._flowbox, self._listbox):
- child = container.get_first_child()
- while child:
- nxt = child.get_next_sibling()
- container.remove(child)
- child = nxt
-
- def _list_videos(self) -> list[str]:
- if not os.path.isdir(self._videos_dir):
- return []
- exts = (".mkv", ".mp4", ".webm", ".avi", ".mov")
- files: list[str] = []
- for entry in sorted(
- os.scandir(self._videos_dir),
- key=lambda e: e.stat().st_mtime,
- reverse=True,
- ):
- if entry.is_file() and entry.name.lower().endswith(exts):
- files.append(entry.path)
- return files
-
- # ── Grid item ────────────────────────────────────────────────────
-
- def _make_grid_item(self, m: _VideoMeta) -> Gtk.Widget | None:
- pixbuf = self._load_pixbuf(m.thumb_path, self.THUMB_SIZE) if m.thumb_path else None
-
- if pixbuf:
- texture = Gdk.Texture.new_for_pixbuf(pixbuf)
- picture = Gtk.Picture.new_for_paintable(texture)
- picture.set_content_fit(Gtk.ContentFit.COVER)
- else:
- picture = Gtk.Image.new_from_icon_name("video-x-generic-symbolic")
- picture.set_pixel_size(48)
- picture.set_size_request(self.THUMB_SIZE, self.THUMB_SIZE)
- picture.add_css_class("card")
-
- # Inner overlay: play icon + duration
- inner = Gtk.Overlay()
- inner.set_child(picture)
-
- play_icon = Gtk.Image.new_from_icon_name("media-playback-start-symbolic")
- play_icon.set_pixel_size(32)
- play_icon.set_opacity(0.8)
- play_icon.set_halign(Gtk.Align.CENTER)
- play_icon.set_valign(Gtk.Align.CENTER)
- inner.add_overlay(play_icon)
-
- if m.duration:
- dur_label = Gtk.Label(label=m.duration)
- dur_label.add_css_class("caption")
- dur_box = Gtk.Box()
- dur_box.append(dur_label)
- dur_box.add_css_class("osd")
- dur_box.set_halign(Gtk.Align.END)
- dur_box.set_valign(Gtk.Align.END)
- dur_box.set_margin_end(4)
- dur_box.set_margin_bottom(4)
- inner.add_overlay(dur_box)
-
- # Outer overlay
- outer = Gtk.Overlay()
-
- if self._selection_mode:
- check = Gtk.CheckButton(active=m.path in self._selected)
- check.set_halign(Gtk.Align.START)
- check.set_valign(Gtk.Align.START)
- check.set_margin_start(6)
- check.set_margin_top(6)
- check.add_css_class("osd")
- check.connect("toggled", self._on_check_toggled, m.path)
-
- btn = Gtk.Button()
- btn.set_child(inner)
- btn.add_css_class("flat")
- btn.connect("clicked", self._on_grid_check_click, check)
- outer.set_child(btn)
- outer.add_overlay(check)
- else:
- btn = Gtk.Button()
- btn.set_child(inner)
- btn.add_css_class("flat")
- btn.set_tooltip_text(m.name)
- btn.connect("clicked", self._on_open_video, m.path)
- outer.set_child(btn)
-
- del_btn = Gtk.Button.new_from_icon_name("user-trash-symbolic")
- del_btn.add_css_class("osd")
- del_btn.add_css_class("circular")
- del_btn.add_css_class("delete-thumb-btn")
- del_btn.set_halign(Gtk.Align.END)
- del_btn.set_valign(Gtk.Align.START)
- del_btn.set_margin_end(4)
- del_btn.set_margin_top(4)
- del_btn.set_tooltip_text(_("Delete"))
- del_btn.connect("clicked", self._on_delete_clicked, m.path)
- outer.add_overlay(del_btn)
-
- return outer
-
- def _on_grid_check_click(self, _btn: Gtk.Button, check: Gtk.CheckButton) -> None:
- check.set_active(not check.get_active())
-
- # ── List item ────────────────────────────────────────────────────
-
- def _make_list_item(self, m: _VideoMeta) -> Gtk.Widget | None:
- parts = []
- if m.duration:
- parts.append(m.duration)
- if m.size:
- parts.append(_human_size(m.size))
- if m.mtime:
- parts.append(_human_date(m.mtime))
-
- row = Adw.ActionRow(title=m.name, subtitle=" · ".join(parts))
- row.set_activatable(not self._selection_mode)
-
- # Small thumbnail prefix
- pixbuf = self._load_pixbuf(m.thumb_path, self.LIST_THUMB) if m.thumb_path else None
- if pixbuf:
- texture = Gdk.Texture.new_for_pixbuf(pixbuf)
- pic = Gtk.Picture.new_for_paintable(texture)
- pic.set_content_fit(Gtk.ContentFit.COVER)
- pic.set_size_request(self.LIST_THUMB, self.LIST_THUMB)
- frame = Gtk.Frame()
- frame.set_child(pic)
- row.add_prefix(frame)
- else:
- icon = Gtk.Image.new_from_icon_name("video-x-generic-symbolic")
- icon.set_pixel_size(self.LIST_THUMB)
- row.add_prefix(icon)
-
- if self._selection_mode:
- check = Gtk.CheckButton(active=m.path in self._selected)
- check.set_valign(Gtk.Align.CENTER)
- check.connect("toggled", self._on_check_toggled, m.path)
- row.add_suffix(check)
- row.connect("activated", lambda _r, c=check: c.set_active(not c.get_active()))
- else:
- row.connect("activated", self._on_row_activated, m.path)
- del_btn = Gtk.Button.new_from_icon_name("user-trash-symbolic")
- del_btn.add_css_class("flat")
- del_btn.set_valign(Gtk.Align.CENTER)
- del_btn.set_tooltip_text(_("Delete"))
- del_btn.connect("clicked", self._on_delete_clicked, m.path)
- row.add_suffix(del_btn)
-
- return row
-
- def _on_row_activated(self, _row: Adw.ActionRow, path: str) -> None:
- self._on_open_video(None, path)
-
- # ── Selection ────────────────────────────────────────────────────
-
- def _on_check_toggled(self, check: Gtk.CheckButton, path: str) -> None:
- if check.get_active():
- self._selected.add(path)
- else:
- self._selected.discard(path)
- self._update_sel_label()
-
- def _on_select_all(self, _btn: Gtk.Button) -> None:
- paths = [m.path for m in self._metas]
- all_selected = len(self._selected) == len(paths)
- self._selected = set() if all_selected else set(paths)
- self._update_sel_label()
- self._rebuild_from_cache()
-
- def _on_delete_selected(self, _btn: Gtk.Button) -> None:
- if not self._selected:
- return
- n = len(self._selected)
- dialog = Adw.AlertDialog(
- heading=_("Delete %d videos?") % n,
- body=_("These videos will be permanently deleted."),
- )
- dialog.add_response("cancel", _("Cancel"))
- dialog.add_response("delete", _("Delete"))
- dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
- dialog.set_default_response("cancel")
- dialog.set_close_response("cancel")
- dialog.connect("response", self._on_bulk_delete_response)
- dialog.present(self.get_root())
-
- def _on_bulk_delete_response(self, _dialog: Adw.AlertDialog, response: str) -> None:
- if response != "delete":
- return
- for p in list(self._selected):
- try:
- os.remove(p)
- except OSError:
- pass
- thumb = self._get_thumb_path(p)
- if thumb:
- try:
- os.remove(thumb)
- except OSError:
- pass
- self._selected.clear()
- self._update_sel_label()
- self.refresh()
-
- # ── Thumbnail helpers ────────────────────────────────────────────
-
- def _get_thumb_path(self, video_path: str) -> str | None:
- thumbs = xdg.thumbs_dir()
- os.makedirs(thumbs, exist_ok=True)
- basename = os.path.splitext(os.path.basename(video_path))[0]
- return os.path.join(thumbs, f"{basename}.jpg")
-
- def _load_pixbuf(
- self, path: str | None, size: int
- ) -> GdkPixbuf.Pixbuf | None:
- if not path or not os.path.isfile(path):
- return None
- try:
- return GdkPixbuf.Pixbuf.new_from_file_at_scale(path, size, size, True)
- except Exception:
- return None
-
- def _generate_thumb_file(self, video_path: str, thumb_path: str) -> None:
- try:
- subprocess.run(
- [
- "ffmpeg", "-y", "-i", video_path,
- "-ss", "00:00:01", "-frames:v", "1",
- "-vf", f"scale={self.THUMB_SIZE}:-1",
- "-q:v", "5", thumb_path,
- ],
- capture_output=True,
- timeout=10,
- )
- except Exception:
- log.debug("Thumbnail generation failed for %s", video_path, exc_info=True)
-
- def _get_duration(self, path: str) -> str | None:
- try:
- result = subprocess.run(
- [
- "ffprobe", "-v", "error",
- "-show_entries", "format=duration",
- "-of", "default=noprint_wrappers=1:nokey=1",
- path,
- ],
- capture_output=True,
- text=True,
- timeout=5,
- )
- secs = float(result.stdout.strip())
- mins = int(secs // 60)
- secs_rem = int(secs % 60)
- return f"{mins}:{secs_rem:02d}"
- except Exception:
- return None
-
- # ── Actions ──────────────────────────────────────────────────────
-
- def _on_open_video(self, _btn: Gtk.Button | None, path: str) -> None:
- uri = GLib.filename_to_uri(path)
- Gtk.show_uri(self.get_root(), uri, Gdk.CURRENT_TIME)
-
- def _on_open_folder(self, _btn: Gtk.Button) -> None:
- os.makedirs(self._videos_dir, exist_ok=True)
- uri = GLib.filename_to_uri(self._videos_dir)
- Gtk.show_uri(self.get_root(), uri, Gdk.CURRENT_TIME)
- def _on_delete_clicked(self, _btn: Gtk.Button, path: str) -> None:
- dialog = Adw.AlertDialog(
- heading=_("Delete video?"),
- body=_('"%s" will be permanently deleted.') % os.path.basename(path),
- )
- dialog.add_response("cancel", _("Cancel"))
- dialog.add_response("delete", _("Delete"))
- dialog.set_response_appearance("delete", Adw.ResponseAppearance.DESTRUCTIVE)
- dialog.set_default_response("cancel")
- dialog.set_close_response("cancel")
- dialog.connect("response", self._on_delete_response, path)
- dialog.present(self.get_root())
- def _on_delete_response(
- self, _dialog: Adw.AlertDialog, response: str, path: str
- ) -> None:
- if response != "delete":
- return
- try:
- os.remove(path)
- except OSError:
- pass
- thumb = self._get_thumb_path(path)
- if thumb:
- try:
- os.remove(thumb)
- except OSError:
- pass
- self.refresh()
+class VideoGallery(MediaGallery):
+ def __init__(self):
+ super().__init__("video", xdg.videos_dir())
diff --git a/usr/share/biglinux/bigcam/ui/window.py b/usr/share/biglinux/bigcam/ui/window.py
index 8570e1b..a2f9dd9 100644
--- a/usr/share/biglinux/bigcam/ui/window.py
+++ b/usr/share/biglinux/bigcam/ui/window.py
@@ -41,7 +41,10 @@
from ui.resource_warning_dialog import show_resource_warning, MONITOR_ENABLED_KEY
from utils.settings_manager import SettingsManager
from utils.async_worker import run_async
-from utils.i18n import _
+from utils.i18n import _, ngettext
+from utils.media_paths import reserve_media_path
+from utils import xdg
+import time
log = logging.getLogger(__name__)
@@ -55,6 +58,11 @@ def __init__(self, app: Adw.Application) -> None:
self.set_size_request(700, 500)
self.add_css_class("bigcam")
+ self._closing = False
+ self._selection_generation = 0
+ self._capture_pending = False
+ self._recording_hold = False
+ self._close_hold = False
self._settings = SettingsManager()
self._camera_manager = CameraManager()
self._stream_engine = StreamEngine(self._camera_manager)
@@ -69,6 +77,8 @@ def __init__(self, app: Adw.Application) -> None:
video_bitrate=self._settings.get("recording-video-bitrate"),
)
self._stream_engine._video_recorder = self._video_recorder
+ self._video_recorder.connect("state-changed", self._on_recording_state)
+ self._video_recorder.connect("finalized", self._on_recording_finalized)
self._audio_monitor = AudioMonitor()
self._tooltip_widgets: list[tuple[Gtk.Widget, str]] = []
@@ -89,6 +99,8 @@ def __init__(self, app: Adw.Application) -> None:
self._setup_immersion()
self._mobile_device_ctrl = MobileDeviceController(self._camera_manager, self._immersion, self._audio_monitor)
+ self._mobile_device_ctrl.phone_server.set_audio_callback(
+ lambda pcm: self._video_recorder.write_audio("phone_browser", pcm))
event_bus.connect("mobile-status-changed", self._on_mobile_status_changed)
event_bus.connect("camera-changed", self._on_eventbus_camera_changed)
event_bus.connect("vcam-limit-reached", self._on_vcam_limit_reached)
@@ -101,6 +113,9 @@ def __init__(self, app: Adw.Application) -> None:
self._setup_resource_monitor()
# Initial camera detection
+ ip_cameras = self._settings.get("ip_cameras")
+ if ip_cameras:
+ GLib.idle_add(self._camera_manager.add_ip_cameras, ip_cameras)
GLib.idle_add(self._camera_manager.detect_cameras_async)
GLib.idle_add(self._update_last_photo_thumbnail)
@@ -111,9 +126,11 @@ def __init__(self, app: Adw.Application) -> None:
def _register_tooltip(self, widget: Gtk.Widget, text: str) -> None:
widget.set_tooltip_text(text)
+ widget.update_property([Gtk.AccessibleProperty.LABEL], [text])
self._tooltip_widgets.append((widget, text))
def _update_tooltip(self, widget: Gtk.Widget, text: str) -> None:
+ widget.update_property([Gtk.AccessibleProperty.LABEL], [text])
for i, (w, _) in enumerate(self._tooltip_widgets):
if w is widget:
self._tooltip_widgets[i] = (widget, text)
@@ -260,6 +277,7 @@ def _build_ui(self) -> None:
pin_btn.add_css_class("pin-btn")
pin_btn.connect("toggled", self._on_always_on_top_toggled)
self._pin_btn = pin_btn
+ pin_btn.set_visible(False) # No portable GTK4/Wayland keep-above protocol.
top_bar.append(pin_btn)
# Phone button (left of camera selector)
@@ -650,6 +668,7 @@ def _build_ui(self) -> None:
"settings": (self._settings_page, _("Settings"), "configure"),
}
self._sidebar_ctrl = SidebarController(self._split_view, stack_pages)
+ self._view_stack = self._sidebar_ctrl.stack
# React to sidebar visibility for immersion
self._split_view.connect("notify::show-sidebar", self._on_sidebar_toggled)
@@ -695,73 +714,42 @@ def _on_zoom_btn_clicked(self, _btn: Gtk.Button) -> None:
self._zoom_btn.set_label(label)
self._stream_engine.set_zoom(level)
- def _on_last_photo_clicked(self, _btn: Gtk.Button) -> None:
- """Open the last captured photo or video with the default viewer."""
- if self._current_mode == "video":
- path = self._get_last_video_path()
- else:
- path = self._get_last_photo_path()
+ def _on_last_photo_clicked(self, _button):
+ path = getattr(self, "_last_media_path", None)
if path:
Gtk.FileLauncher.new(Gio.File.new_for_path(path)).launch(self, None, None, None)
- def _update_last_media_thumbnail(self, specific_path: str | None = None) -> bool:
- """Refresh the circular thumbnail based on current mode.
- Returns False so it can be used with GLib.timeout_add.
- If specific_path is given, show that file directly instead of scanning."""
- if specific_path:
- path = specific_path
- if self._current_mode == "video":
- tooltip = _("Last video")
- else:
- tooltip = _("Last photo")
- elif self._current_mode == "video":
- path = self._get_last_video_path()
- tooltip = _("Last video")
- else:
- path = self._get_last_photo_path()
- tooltip = _("Last photo")
- self._update_tooltip(self._last_photo_btn, tooltip)
- if path and os.path.isfile(path):
- is_video = path.lower().endswith((".mp4", ".mkv", ".webm", ".avi"))
- if is_video:
- thumb_path = path + ".thumb.png"
- if os.path.exists(thumb_path):
- self._set_video_thumbnail(thumb_path)
- else:
- # Show placeholder immediately, generate thumbnail in background
- icon = Gtk.Image.new_from_icon_name("video-x-generic-symbolic")
- icon.set_pixel_size(24)
- self._last_photo_btn.set_child(icon)
- self._last_photo_btn.set_visible(True)
-
- def _gen_thumb() -> str | None:
- subprocess.run(
- ["ffmpeg", "-y", "-i", path, "-ss", "00:00:00",
- "-vframes", "1", "-vf", "scale=40:40:force_original_aspect_ratio=increase,crop=40:40",
- thumb_path],
- capture_output=True, timeout=5,
- )
- return thumb_path if os.path.exists(thumb_path) else None
-
- def _on_thumb_done(result: str | None) -> None:
- if result:
- self._set_video_thumbnail(result)
-
- run_async(_gen_thumb, on_success=_on_thumb_done)
+ def _update_last_media_thumbnail(self, specific_path=None):
+ if self._closing:
+ return GLib.SOURCE_REMOVE
+ from core.media_library import scan, thumbnail, MediaEntry, PHOTO_EXTS, VIDEO_EXTS
+ mode = self._current_mode
+ self._thumbnail_generation = getattr(self, "_thumbnail_generation", 0) + 1
+ generation = self._thumbnail_generation
+ def prepare():
+ if specific_path:
+ st = os.stat(specific_path, follow_symlinks=False)
+ entry = MediaEntry(specific_path, st.st_size, st.st_mtime, st.st_mtime_ns, mode == "video")
else:
- try:
- from gi.repository import GdkPixbuf
- pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale(path, 40, 40, True)
- texture = Gdk.Texture.new_for_pixbuf(pixbuf)
- image = Gtk.Image.new_from_paintable(texture)
- image.set_pixel_size(40)
- self._last_photo_btn.set_child(image)
- self._last_photo_btn.set_visible(True)
- except Exception:
- self._last_photo_btn.set_visible(False)
- else:
- self._last_photo_btn.set_visible(False)
- return False
+ entries = scan(xdg.videos_dir() if mode == "video" else xdg.photos_dir(),
+ VIDEO_EXTS if mode == "video" else PHOTO_EXTS)
+ if not entries:
+ return None
+ entry = entries[0]
+ return entry.path, thumbnail(entry)
+ def done(result):
+ if self._closing or generation != self._thumbnail_generation or mode != self._current_mode:
+ return
+ self._last_media_path = result[0] if result else None
+ self._last_photo_btn.set_visible(bool(result))
+ self._update_tooltip(self._last_photo_btn, _("Last video") if mode == "video" else _("Last photo"))
+ if result:
+ picture = Gtk.Picture.new_for_filename(result[1])
+ picture.set_content_fit(Gtk.ContentFit.COVER)
+ picture.set_size_request(40, 40)
+ self._last_photo_btn.set_child(picture)
+ run_async(prepare, on_success=done, on_error=lambda exc: done(None))
+ return GLib.SOURCE_REMOVE
def _set_video_thumbnail(self, thumb_path: str) -> None:
try:
@@ -838,6 +826,8 @@ def _on_grid_btn_toggled(self, btn: Gtk.ToggleButton) -> None:
visible = btn.get_active()
self._preview.set_grid_visible(visible)
self._settings.set("grid_overlay", visible)
+ if self._settings_page._grid_row.get_active() != visible:
+ self._settings_page._grid_row.set_active(visible)
def _on_mirror_btn_toggled(self, btn: Gtk.ToggleButton) -> None:
new_val = btn.get_active()
@@ -872,19 +862,12 @@ def _on_settings_vcam_changed(self, row: object, _pspec: object) -> None:
self._vcam_quick_btn.handler_unblock(self._vcam_btn_handler_id)
self._update_vcam_badge()
- def _update_vcam_badge(self) -> None:
- if not self._settings.get("virtual-camera-enabled"):
- self._vcam_badge.set_visible(False)
- return
-
- disabled_list = self._settings.get("vcam-disabled-cameras", [])
- active_count = sum(1 for c in self._camera_manager.cameras if c.id not in disabled_list)
-
- if active_count > 0:
- self._vcam_badge.set_label(str(active_count))
- self._vcam_badge.set_visible(True)
- else:
- self._vcam_badge.set_visible(False)
+ def _update_vcam_badge(self):
+ count = len(VirtualCamera._allocations) if VirtualCamera.is_enabled() else 0
+ self._vcam_badge.set_label(str(count))
+ self._vcam_badge.set_visible(count > 0)
+ self._vcam_badge.update_property([Gtk.AccessibleProperty.LABEL],
+ [ngettext("%d virtual camera allocated", "%d virtual cameras allocated", count) % count])
def _on_help_tooltips_changed(self, _page: object, enabled: bool) -> None:
self._set_tooltips_enabled(enabled)
@@ -928,49 +911,17 @@ def _on_toggle_fullscreen_action(self, *_args) -> None:
else:
self.fullscreen()
- def _on_escape_action(self, *_args) -> None:
- if self._split_view.get_show_sidebar():
+ def _on_escape_action(self, *_args):
+ if self._preview.is_countdown_active():
+ self._preview.cancel_countdown()
+ self._show_notification(_("Capture cancelled."))
+ elif self._split_view.get_show_sidebar():
self._split_view.set_show_sidebar(False)
elif self.is_fullscreen():
self.unfullscreen()
- def _on_always_on_top_toggled(self, btn: Gtk.ToggleButton) -> None:
- on_top = btn.get_active()
-
- def _apply_always_on_top() -> None:
- script = f'workspace.activeWindow.keepAbove = {"true" if on_top else "false"};'
- runtime_dir = os.environ.get('XDG_RUNTIME_DIR', '/tmp')
- script_path = os.path.join(runtime_dir, 'kwin_bigcam_above.js')
- plugin_name = 'bigcam_above'
- try:
- with open(script_path, 'w') as f:
- f.write(script)
- result = subprocess.run(
- ['qdbus', 'org.kde.KWin', '/Scripting',
- 'org.kde.kwin.Scripting.loadScript', script_path, plugin_name],
- capture_output=True, text=True, timeout=5,
- )
- script_id = result.stdout.strip()
- if script_id.isdigit():
- subprocess.run(
- ['qdbus', 'org.kde.KWin', f'/Scripting/Script{script_id}',
- 'org.kde.kwin.Script.run'],
- capture_output=True, timeout=5,
- )
- subprocess.run(
- ['qdbus', 'org.kde.KWin', '/Scripting',
- 'org.kde.kwin.Scripting.unloadScript', plugin_name],
- capture_output=True, timeout=5,
- )
- except (FileNotFoundError, OSError):
- pass
- finally:
- try:
- os.unlink(script_path)
- except OSError:
- pass
-
- run_async(_apply_always_on_top)
+ def _on_always_on_top_toggled(self, btn):
+ self._show_notification(_("Use your window manager to keep this window above others."))
def _on_show_welcome_action(self, *_args) -> None:
from ui.welcome_dialog import WelcomeDialog
@@ -979,10 +930,13 @@ def _on_show_welcome_action(self, *_args) -> None:
dialog._dialog.connect("closed", lambda *_: self._immersion.uninhibit())
dialog.present()
- def _is_editing_text(self) -> bool:
- """Return True when focus is on a text input (skip global shortcuts)."""
+ def _is_editing_text(self):
focus = self.get_focus()
- return isinstance(focus, Gtk.Text)
+ while focus is not None:
+ if isinstance(focus, (Gtk.Editable, Gtk.TextView)):
+ return True
+ focus = focus.get_parent()
+ return False
def _set_zoom_level(self, index: int) -> None:
if self._is_editing_text():
@@ -1029,6 +983,8 @@ def _cycle_timer(self) -> None:
def _trigger_flash(self) -> None:
"""Show a brief white flash overlay on photo capture."""
+ if self._settings.get("reduce-motion") or not Gtk.Settings.get_default().get_property("gtk-enable-animations"):
+ return
self._flash_overlay.set_opacity(0.8)
GLib.timeout_add(100, self._flash_fade_out)
@@ -1038,6 +994,8 @@ def _show_notification(self, message: str, _level: str = "info", timeout_ms: int
GLib.source_remove(self._window_banner_timeout)
self._window_banner_timeout = None
self._window_banner.set_title(message)
+ if hasattr(self._window_banner, "announce"):
+ self._window_banner.announce(message, Gtk.AccessibleAnnouncementPriority.MEDIUM)
self._window_banner.set_revealed(True)
if timeout_ms > 0:
self._window_banner_timeout = GLib.timeout_add(
@@ -1055,6 +1013,7 @@ def _flash_fade_out(self) -> bool:
def _start_rec_timer(self) -> None:
"""Start the recording duration timer in the top bar."""
+ self._rec_started_at = time.monotonic()
self._rec_timer_seconds = 0
self._rec_timer_label.set_label("00:00")
self._rec_timer_box.set_visible(True)
@@ -1069,7 +1028,7 @@ def _stop_rec_timer(self) -> None:
self._rec_timer_seconds = 0
def _update_rec_timer(self) -> bool:
- self._rec_timer_seconds += 1
+ self._rec_timer_seconds = int(time.monotonic() - self._rec_started_at)
mins, secs = divmod(self._rec_timer_seconds, 60)
self._rec_timer_label.set_label(f"{mins:02d}:{secs:02d}")
return GLib.SOURCE_CONTINUE
@@ -1149,10 +1108,10 @@ def _setup_shortcuts(self) -> None:
"win.toggle-grid": ["g"],
"win.cycle-timer": ["t"],
"win.toggle-fullscreen": ["F11"],
- "win.toggle-sidebar": ["Tab"],
- "win.zoom-1x": ["1"],
- "win.zoom-1.5x": ["2"],
- "win.zoom-2x": ["3"],
+ "win.toggle-sidebar": ["F9"],
+ "win.zoom-1x": ["1"],
+ "win.zoom-1.5x": ["2"],
+ "win.zoom-2x": ["3"],
"win.escape": ["Escape"],
"win.switch-tab-1": ["1"],
"win.switch-tab-2": ["2"],
@@ -1251,7 +1210,21 @@ def _pick_preferred_format(self, camera: CameraInfo):
def _on_camera_selected(
self, _selector: CameraSelector, camera: CameraInfo
) -> None:
- log.info(">>> _on_camera_selected: %s (%s)", camera.name, camera.id)
+ if self._closing:
+ return
+ if self._video_recorder.is_recording or self._video_recorder.is_finalizing or self._capture_pending:
+ self._show_notification(_("Finish the current capture before changing cameras."), "warning")
+ if self._active_camera:
+ cameras = self._camera_manager.cameras
+ for index, candidate in enumerate(cameras):
+ if candidate.id == self._active_camera.id:
+ self._camera_selector.set_selected_silent(index)
+ break
+ return
+ self._preview.cancel_countdown()
+ self._selection_generation += 1
+ generation = self._selection_generation
+ log.info("Camera selection started")
# Skip if same camera is already active — period.
if self._active_camera and self._active_camera.id == camera.id:
log.info("Camera %s already active, skipping", camera.name)
@@ -1375,6 +1348,8 @@ def do_controls_then_stream() -> tuple[bool, list]:
self._streaming_lock.release()
def on_done(result: tuple[bool, list]) -> None:
+ if self._closing or generation != self._selection_generation:
+ return
success, controls = result
log.debug(f"on_done: success={success}, controls={len(controls)}")
self._dismiss_notification()
@@ -1422,7 +1397,13 @@ def on_done(result: tuple[bool, list]) -> None:
# Unblock dropdown signals after async setup completes
self._camera_selector.unblock_signals()
- run_async(do_controls_then_stream, on_success=on_done)
+ def on_setup_error(exc):
+ if not self._closing and generation == self._selection_generation:
+ self._camera_selector.unblock_signals()
+ self._preview._on_error(self._stream_engine, _("Failed to start camera streaming."))
+ if self._settings.get("hotplug_enabled"):
+ self._camera_manager.start_hotplug()
+ run_async(do_controls_then_stream, on_success=on_done, on_error=on_setup_error)
else:
# V4L2, libcamera, PipeWire: load controls async + start stream
self._preview.show_status(
@@ -1529,18 +1510,26 @@ def _on_prefer_v4l2_changed(self, _page, prefer: bool) -> None:
self._stream_engine.prefer_v4l2 = prefer
def _on_resolution_changed(self, _page, value: str) -> None:
+ if self._video_recorder.is_recording or self._video_recorder.is_finalizing:
+ self._show_notification(_("Camera preferences will apply after recording stops."))
+ return
if self._active_camera:
log.info("Resolution changed to '%s', restarting stream", value)
preferred_fmt = self._pick_preferred_format(self._active_camera)
self._stream_engine.play(self._active_camera, fmt=preferred_fmt)
def _on_fps_limit_changed(self, _page, value: int) -> None:
+ if self._video_recorder.is_recording or self._video_recorder.is_finalizing:
+ self._show_notification(_("Camera preferences will apply after recording stops."))
+ return
if self._active_camera:
preferred_fmt = self._pick_preferred_format(self._active_camera)
self._stream_engine.play(self._active_camera, fmt=preferred_fmt)
- def _on_grid_overlay_changed(self, _page, visible: bool) -> None:
+ def _on_grid_overlay_changed(self, _page, visible):
self._preview.set_grid_visible(visible)
+ if self._grid_btn.get_active() != visible:
+ self._grid_btn.set_active(visible)
def _on_overlay_opacity_changed(self, _page, value: int) -> None:
self._apply_overlay_opacity(value)
@@ -1680,6 +1669,8 @@ def _on_qr_detected(self, _page: Any, text: str) -> None:
# -- Capture -------------------------------------------------------------
def _on_capture(self, _preview: PreviewArea) -> None:
+ if self._closing or self._capture_pending:
+ return
if not self._active_camera:
self._show_notification(_("No camera selected."), "warning")
return
@@ -1699,7 +1690,8 @@ def _on_capture(self, _preview: PreviewArea) -> None:
dialog.add_response("native", _("Camera photo (full resolution)"))
dialog.set_response_appearance("native", Adw.ResponseAppearance.SUGGESTED)
dialog.set_default_response("native")
- dialog.set_close_response("webcam")
+ dialog.add_response("cancel", _("Cancel"))
+ dialog.set_close_response("cancel")
dialog.connect("response", self._on_capture_mode_response)
self._immersion.present_dialog(dialog, self)
return
@@ -1714,6 +1706,8 @@ def _on_capture(self, _preview: PreviewArea) -> None:
def _on_capture_mode_response(
self, _dialog: Adw.AlertDialog, response: str
) -> None:
+ if response not in {"native", "webcam"} or self._closing:
+ return
capture_fn = self._do_native_capture if response == "native" else self._do_webcam_capture
timer = self._settings.get("capture-timer")
if timer and timer > 0:
@@ -1737,7 +1731,8 @@ def _show_movie_mode_dialog(self, camera: CameraInfo) -> None:
dialog.add_response("retry", _("Try again"))
dialog.set_response_appearance("retry", Adw.ResponseAppearance.SUGGESTED)
dialog.set_default_response("retry")
- dialog.set_close_response("frame")
+ dialog.add_response("cancel", _("Cancel"))
+ dialog.set_close_response("cancel")
def _on_response(_dlg: Adw.AlertDialog, resp: str) -> None:
# Always resume streaming first
@@ -1752,114 +1747,69 @@ def _on_response(_dlg: Adw.AlertDialog, resp: str) -> None:
dialog.connect("response", _on_response)
self._immersion.present_dialog(dialog, self)
- def _do_webcam_capture(self) -> None:
- self._trigger_flash()
- self._show_notification(_("Capturing photo…"), "info", 1500)
-
- import time as _time
- from utils import xdg
-
- timestamp = _time.strftime("%Y%m%d_%H%M%S")
- output_dir = xdg.photos_dir()
- os.makedirs(output_dir, exist_ok=True)
- output_path = os.path.join(output_dir, f"bigcam_{timestamp}.png")
-
- ok = self._stream_engine.capture_snapshot(output_path)
- if ok:
+ def _do_webcam_capture(self):
+ if self._closing or self._capture_pending or not self._active_camera:
+ return
+ self._capture_pending = True
+ generation = self._selection_generation
+ self._show_notification(_("Capturing photo…"), timeout_ms=0)
+ def capture():
+ path = reserve_media_path(xdg.photos_dir(), ".png")
+ if not self._stream_engine.capture_snapshot(path):
+ os.unlink(path)
+ raise RuntimeError("No fresh frame could be saved")
+ return path
+ def saved(path):
+ self._capture_pending = False
+ if self._closing or generation != self._selection_generation:
+ return
+ self._trigger_flash()
self._show_notification(_("Photo saved!"), "success")
self._gallery.refresh()
- self._update_last_media_thumbnail(output_path)
- else:
- self._show_notification(
- _("Failed to capture photo."), "error"
- )
-
- def _do_native_capture(self) -> None:
- self._trigger_flash()
+ self._update_last_media_thumbnail(path)
+ def failed(exc):
+ self._capture_pending = False
+ if not self._closing:
+ self._show_notification(_("Failed to capture photo."), "error", 0)
+ run_async(capture, on_success=saved, on_error=failed)
+
+ def _do_native_capture(self):
camera = self._active_camera
- if not camera:
+ if not camera or self._closing or self._capture_pending:
return
-
- # Show waiting state in preview
- self._stream_engine.stop()
- self._preview.show_status(
- _("Please wait…"),
- _("Switching to photography mode."),
- "camera-photo-symbolic",
- loading=True,
- )
-
- def _capture_in_thread() -> str | None:
- import time as _time
- from utils import xdg
-
- # Kill ALL gphoto2/ffmpeg processes to guarantee a clean USB bus
- self._camera_manager.get_backend(camera.backend).stop_streaming()
-
- # Give the USB device time to be fully released after killing
- # the streaming process — Canon DSLRs need this.
- _time.sleep(2)
-
- # Check if camera is stuck in Movie mode (some models can't
- # capture stills in this mode). Return a sentinel so the
- # main thread can show a dialog instead of waiting for a
- # futile 60-second timeout.
- try:
- port = camera.extra.get("port", camera.device_path)
- res = subprocess.run(
- ["gphoto2", "--port", port,
- "--get-config", "autoexposuremode"],
- capture_output=True, text=True, timeout=8,
- )
- for line in res.stdout.splitlines():
- if line.startswith("Current:") and "Movie" in line:
- return "__movie_mode__"
- except Exception:
- pass
-
- timestamp = _time.strftime("%Y%m%d_%H%M%S")
- output_dir = xdg.photos_dir()
- os.makedirs(output_dir, exist_ok=True)
- output_path = os.path.join(output_dir, f"bigcam_{timestamp}.jpg")
-
- ok = self._camera_manager.capture_photo(camera, output_path)
- if ok and self._stream_engine.mirror:
- try:
- import cv2
- img = cv2.imread(output_path)
- if img is not None:
- img = cv2.flip(img, 1)
- cv2.imwrite(output_path, img)
- except Exception as exc:
- log.warning("Failed to mirror native photo: %s", exc)
- return output_path if ok else None
-
- def _on_done(result: str | None) -> None:
- if result == "__movie_mode__":
- self._show_movie_mode_dialog(camera)
+ self._capture_pending = True
+ generation = self._selection_generation
+ self._camera_manager.stop_hotplug()
+ self._stream_engine.stop(stop_backend=False)
+ self._preview.show_status(_("Please wait…"), _("Switching to photography mode."), loading=True)
+ def capture():
+ path = reserve_media_path(xdg.photos_dir(), ".jpg")
+ if not self._camera_manager.capture_photo(camera, path):
+ if os.path.getsize(path) == 0:
+ os.unlink(path)
+ raise RuntimeError("Native capture failed")
+ # Preserve the original JPEG and its EXIF/ICC metadata byte-for-byte.
+ return path
+ def resume(path=None, error=None):
+ self._capture_pending = False
+ if self._closing or generation != self._selection_generation:
return
- if result:
- self._show_notification(_("Photo saved!"), "success")
+ if path:
+ self._trigger_flash()
self._gallery.refresh()
- self._update_last_media_thumbnail(result)
+ self._update_last_media_thumbnail(path)
+ self._show_notification(_("Photo saved!"), "success")
else:
- self._show_notification(
- _("Failed to capture photo."), "error"
- )
- # Resume streaming — clear active camera so the guard doesn't skip
+ self._show_notification(_("Failed to capture photo. Check the camera mode and connection."), "error", 0)
self._active_camera = None
- self._preview.show_status(
- _("Please wait…"),
- _("Resuming camera streaming…"),
- "camera-web-symbolic",
- loading=True,
- )
self._on_camera_selected(self._camera_selector, camera)
+ run_async(capture, on_success=lambda path: resume(path), on_error=lambda exc: resume(error=exc))
- run_async(_capture_in_thread, on_success=_on_done)
-
- def _on_refresh(self, *_args) -> None:
- """Full camera reload: stop current stream, clear state, re-detect."""
+ def _on_refresh(self, *_args):
+ if self._closing or self._video_recorder.is_recording or self._video_recorder.is_finalizing or self._capture_pending:
+ return
+ self._selection_generation += 1
+ self._preview.cancel_countdown()
self._stream_engine.stop()
self._active_camera = None
self._camera_manager.detect_cameras_async(force_emit=True)
@@ -1992,19 +1942,8 @@ def _on_device_busy(
busy_camera = self._active_camera
camera_name = busy_camera.name if busy_camera else ""
- # For phone cameras, the producer process (scrcpy, uxplay) is expected
- # on the v4l2loopback device — it's the video source, not a blocker.
- if busy_camera and busy_camera.id.startswith("phone:"):
- expected = {"scrcpy", "uxplay"}
- real_blockers = [a for a in blocking_apps if a not in expected]
- if not real_blockers:
- log.info(
- "device-busy on %s: only expected producers %s — auto-retrying",
- device_path, blocking_apps,
- )
- GLib.timeout_add(1500, lambda: self._retry_camera(busy_camera) or False)
- return
- blocking_apps = real_blockers
+ if busy_camera and busy_camera.backend in (BackendType.PHONE, BackendType.SCRCPY, BackendType.AIRPLAY):
+ blocking_apps = [name for name in blocking_apps if name not in {"scrcpy", "uxplay"}]
# Clear active camera so 'Refresh cameras' can re-select it
self._active_camera = None
@@ -2153,59 +2092,81 @@ def _on_capture_action(self, *_args) -> None:
return
self._on_capture(self._preview)
- def _on_record_toggle(self, *_args) -> None:
- if self._video_recorder.is_recording:
- path = self._video_recorder.stop()
- self._preview.set_recording_state(False)
- self._immersion.uninhibit()
- self._stop_rec_timer()
- # Update capture button state in video mode
- if self._current_mode == "video":
- self._bottom_capture_btn.remove_css_class("recording")
- self._bottom_capture_btn.set_icon_name("media-record-symbolic")
- self._update_tooltip(self._bottom_capture_btn, _("Start recording"))
- if path:
- self._show_notification(
- _("Video saved: %s") % os.path.basename(path), "success"
- )
- self._video_gallery.refresh()
- # Slight delay so the file is fully flushed before thumbnail generation
- GLib.timeout_add(500, self._update_last_media_thumbnail)
- else:
- if not self._active_camera:
- self._show_notification(
- _("No camera selected."), "warning"
- )
- return
- # Build per-source volume dict from AudioMonitor
- source_volumes = {}
- for src_name in self._audio_monitor.all_source_names:
- source_volumes[src_name] = self._audio_monitor.get_source_volume(src_name)
- path = self._video_recorder.start(
+ def _on_record_toggle(self, *_args):
+ if self._closing:
+ return
+ recorder = self._video_recorder
+ if recorder.is_finalizing:
+ self._show_notification(_("Finalizing video…"), timeout_ms=0)
+ return
+ if recorder.is_recording:
+ recorder.stop()
+ return
+ if not self._active_camera or not self._stream_engine.is_playing():
+ self._show_notification(_("No active camera stream."), "warning")
+ return
+ try:
+ sources = self._audio_monitor.all_source_names
+ path = recorder.start(
self._active_camera,
- self._stream_engine.pipeline,
- mirror=self._stream_engine.mirror,
- audio_sources=self._audio_monitor.all_source_names,
- active_audio_sources=self._audio_monitor.active_source_names,
- source_volumes=source_volumes,
+ audio_sources=sources,
+ external_audio=self._audio_monitor.external_recording_sources,
+ active_audio_sources=[name for name in self._audio_monitor.active_source_names if name in sources],
+ source_volumes={name: self._audio_monitor.get_source_volume(name) for name in sources},
muted=self._audio_monitor.muted,
+ fps=self._stream_engine.fps,
)
- if path:
- self._preview.set_recording_state(True)
- self._immersion.inhibit()
- self._start_rec_timer()
- # Update capture button state in video mode
- if self._current_mode == "video":
- self._bottom_capture_btn.add_css_class("recording")
- self._bottom_capture_btn.set_icon_name("media-playback-stop-symbolic")
- self._update_tooltip(self._bottom_capture_btn, _("Stop recording"))
- self._show_notification(
- _("Recording…"), "info", 0, progress=True
- )
+ except (OSError, ValueError):
+ log.exception("Cannot reserve recording output")
+ self._show_notification(_("Failed to start recording."), "error", 0)
+ return
+ if path:
+ if not self._recording_hold:
+ self.get_application().hold()
+ self._recording_hold = True
+ self._immersion.inhibit()
+ self._show_notification(_("Starting recording…"), timeout_ms=0)
+
+
+ def _on_recording_state(self, recorder, state):
+ if self._closing:
+ return
+ recording = state in {"starting", "recording"}
+ finalizing = state == "finalizing"
+ self._preview.set_recording_state(recording)
+ self._mode_photo_btn.set_sensitive(not (recording or finalizing))
+ self._mode_video_btn.set_sensitive(not (recording or finalizing))
+ self._bottom_capture_btn.set_sensitive(not finalizing)
+ if state == "recording":
+ self._start_rec_timer()
+ self._show_notification(_("Recording…"), timeout_ms=0)
+ elif finalizing:
+ self._stop_rec_timer()
+ self._show_notification(_("Finalizing video…"), timeout_ms=0)
+ if self._current_mode == "video":
+ self._bottom_capture_btn.set_icon_name("media-playback-stop-symbolic" if recording else "media-record-symbolic")
+ self._update_tooltip(self._bottom_capture_btn, _("Stop recording") if recording else _("Start recording"))
+ if recording:
+ self._bottom_capture_btn.add_css_class("recording")
else:
- self._show_notification(
- _("Failed to start recording."), "error"
- )
+ self._bottom_capture_btn.remove_css_class("recording")
+
+
+ def _on_recording_finalized(self, recorder, path, success, error):
+ if self._recording_hold:
+ self._recording_hold = False
+ self.get_application().release()
+ if self._closing:
+ return
+ self._stop_rec_timer()
+ self._immersion.uninhibit()
+ if success:
+ self._show_notification(_("Video saved: %s") % os.path.basename(path), "success")
+ self._video_gallery.refresh()
+ self._update_last_media_thumbnail(path)
+ else:
+ self._show_notification(_("Recording failed. Any partial file has been preserved: %s") % os.path.basename(path), "error", 0)
+ log.error("Recording finalization failed: %s", error)
def _on_audio_source_toggled(
self, _monitor: AudioMonitor, source_name: str, active: bool
@@ -2388,6 +2349,8 @@ def _on_window_mapped(self, _window: Adw.ApplicationWindow) -> None:
self._camera_manager.start_hotplug()
def _on_close(self, _window: Adw.ApplicationWindow) -> bool:
+ if self._closing:
+ return True
# Gather ALL active camera sources
active_names: list[str] = []
@@ -2416,7 +2379,7 @@ def _on_close(self, _window: Adw.ApplicationWindow) -> bool:
if label not in active_names:
active_names.append(label)
- has_active = bool(active_names) or self._stream_engine.has_active_bg_vcams()
+ has_active = bool(active_names) or self._stream_engine.has_active_bg_vcams() or self._video_recorder.is_recording or self._video_recorder.is_finalizing
if has_active:
parts = []
@@ -2448,7 +2411,7 @@ def _on_close(self, _window: Adw.ApplicationWindow) -> bool:
# No active pipeline — hide immediately and clean up
self.set_visible(False)
self._cleanup_and_close()
- return False
+ return True
def _on_close_response(self, _dialog: Adw.AlertDialog, response: str) -> None:
if response == "cancel":
@@ -2457,7 +2420,6 @@ def _on_close_response(self, _dialog: Adw.AlertDialog, response: str) -> None:
# Hide window immediately so the user sees it close instantly
self.set_visible(False)
self._cleanup_and_close()
- self.destroy()
else: # keep — hide window, keep pipeline alive
self._camera_manager.stop_hotplug()
self._background_mode = True
@@ -2466,43 +2428,69 @@ def _on_close_response(self, _dialog: Adw.AlertDialog, response: str) -> None:
app.hold()
self.set_visible(False)
- def _cleanup_and_close(self) -> None:
+ def _cleanup_and_close(self):
+ if self._closing:
+ return
+ self._closing = True
+ self._selection_generation += 1
+ app = self.get_application()
+ app.hold()
+ self._close_hold = True
+ self._preview.cancel_countdown()
+ self._preview.cleanup()
+ self._controls_page.cleanup()
+ self._settings_page.cleanup()
+ self._effects_page.cleanup()
+ self._gallery.cleanup()
+ self._video_gallery.cleanup()
self._immersion.cleanup()
+ self._resource_monitor.stop()
self._video_recorder.stop()
self._audio_monitor.stop_all()
- self._audio_monitor.remove_external_source("airplay")
- self._stream_engine.stop()
+ self._camera_manager.close()
+ self._stream_engine.stop(stop_backend=False)
self._stream_engine.stop_all_bg_vcams()
- self._camera_manager.stop_hotplug()
-
- # Stop phone/scrcpy/airplay SYNCHRONOUSLY before the app exits,
- # otherwise the processes become orphans.
- ctrl = self._mobile_device_ctrl
- if ctrl.scrcpy_usb:
- ctrl.scrcpy_usb.stop()
- if ctrl.scrcpy_wifi:
- ctrl.scrcpy_wifi.stop()
- if ctrl.airplay_receiver:
- ctrl.airplay_receiver.stop()
- if ctrl.phone_server and ctrl.phone_server.running:
- ctrl.phone_server.stop()
-
- # Run slow blocking cleanup in background (VirtualCamera, gphoto2).
- def _heavy_cleanup() -> None:
- VirtualCamera.stop()
- VirtualCamera.cleanup_dynamic_devices()
+ self._mobile_device_ctrl.disconnect_signals()
+ def cleanup():
+ # Recording owns a non-daemon finite worker. Native still capture is also
+ # allowed to finish before its camera session is torn down.
+ if not self._video_recorder.wait_finalize(25):
+ raise RuntimeError("Recording is still finalizing")
+ deadline = time.monotonic() + 70
+ while self._capture_pending and time.monotonic() < deadline:
+ time.sleep(0.1)
+ ctrl = self._mobile_device_ctrl
+ for service in (ctrl.scrcpy_usb, ctrl.scrcpy_wifi, ctrl.airplay_receiver, ctrl.phone_server):
+ service.stop()
gp_backend = self._camera_manager.get_backend(BackendType.GPHOTO2)
- if gp_backend and hasattr(gp_backend, "stop_streaming"):
+ if gp_backend:
gp_backend.stop_streaming()
-
- def _on_cleanup_done(_result=None) -> None:
+ VirtualCamera.stop()
+ VirtualCamera.cleanup_dynamic_devices()
+ def done(_result=None):
+ if self._recording_hold:
+ self._recording_hold = False
+ app.release()
if getattr(self, "_background_mode", False):
self._background_mode = False
- app = self.get_application()
- if app is not None:
- app.release()
-
- run_async(_heavy_cleanup, on_success=_on_cleanup_done)
+ app.release()
+ if self._close_hold:
+ self._close_hold = False
+ app.release()
+ self.destroy()
+ def failed(exc):
+ # Never discard a still-finalizing recording just to close quickly.
+ log.error("Cleanup failed: %s", exc)
+ if not self._video_recorder.wait_finalize(0):
+ GLib.timeout_add_seconds(1, check_finalized)
+ else:
+ done()
+ def check_finalized():
+ if not self._video_recorder.wait_finalize(0):
+ return GLib.SOURCE_CONTINUE
+ run_async(cleanup, on_success=done, on_error=lambda exc: done())
+ return GLib.SOURCE_REMOVE
+ run_async(cleanup, on_success=done, on_error=failed)
# -- theme ---------------------------------------------------------------
diff --git a/usr/share/biglinux/bigcam/utils/async_worker.py b/usr/share/biglinux/bigcam/utils/async_worker.py
index e684690..6c7ea2c 100644
--- a/usr/share/biglinux/bigcam/utils/async_worker.py
+++ b/usr/share/biglinux/bigcam/utils/async_worker.py
@@ -1,5 +1,7 @@
-"""Async worker helpers – run I/O off the main thread, deliver results via GLib.idle_add."""
+"""Bounded background I/O with cancellable, single-delivery GTK callbacks."""
+from __future__ import annotations
+from concurrent.futures import Future, ThreadPoolExecutor
import logging
import threading
from typing import Any, Callable
@@ -7,25 +9,76 @@
from gi.repository import GLib
log = logging.getLogger(__name__)
+# Do not create a new OS thread for every slider event or thumbnail.
+_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="bigcam-io")
+_SLOTS = threading.BoundedSemaphore(64)
-def run_async(
- task: Callable[..., Any],
- args: tuple = (),
- on_success: Callable[[Any], None] | None = None,
- on_error: Callable[[Exception], None] | None = None,
-) -> None:
- """Run *task* in a daemon thread; post result/error to the GTK main loop."""
+class TaskHandle:
+ """Cancellation also suppresses callbacks queued before the cancellation.
- def _worker() -> None:
+ Running system calls are not interruptible; they must have finite timeouts.
+ A caller disposing a widget/camera should cancel its associated handles.
+ """
+ def __init__(self):
+ self._cancelled = threading.Event()
+ self.future: Future | None = None
+
+ def cancel(self) -> None:
+ self._cancelled.set()
+ if self.future is not None:
+ self.future.cancel()
+
+ @property
+ def cancelled(self) -> bool:
+ return self._cancelled.is_set()
+
+ def deliver(self, callback, value) -> bool:
+ if callback is not None and not self.cancelled:
+ try:
+ callback(value)
+ except Exception:
+ log.exception("Background task result handler failed")
+ # GLib callbacks must not repeat when an application callback is truthy.
+ return GLib.SOURCE_REMOVE
+
+
+def run_async(task: Callable[..., Any], args: tuple = (),
+ on_success: Callable[[Any], None] | None = None,
+ on_error: Callable[[Exception], None] | None = None) -> TaskHandle:
+ """Submit without blocking the main loop; return a cancellation handle."""
+ handle = TaskHandle()
+ if not _SLOTS.acquire(blocking=False):
+ error = RuntimeError("BigCam background task queue is full")
+ log.warning("%s", error)
+ if on_error:
+ GLib.idle_add(handle.deliver, on_error, error)
+ return handle
+
+ def completed(future):
try:
- result = task(*args)
- if on_success is not None:
- GLib.idle_add(on_success, result)
- except Exception as exc:
- if on_error is not None:
- GLib.idle_add(on_error, exc)
+ if future.cancelled() or handle.cancelled:
+ return
+ error = future.exception()
+ if error is None:
+ if on_success:
+ GLib.idle_add(handle.deliver, on_success, future.result())
+ elif on_error:
+ GLib.idle_add(handle.deliver, on_error, error)
else:
- log.exception("Unhandled error in async task %s", task)
+ log.error("Background task failed", exc_info=(type(error), error, error.__traceback__))
+ finally:
+ _SLOTS.release()
+
+ try:
+ handle.future = _POOL.submit(task, *args)
+ handle.future.add_done_callback(completed)
+ except RuntimeError as error:
+ _SLOTS.release()
+ GLib.idle_add(handle.deliver, on_error, error)
+ return handle
+
- threading.Thread(target=_worker, daemon=True).start()
+def shutdown_workers() -> None:
+ """Cancel queued work; running tasks retain their finite I/O timeouts."""
+ _POOL.shutdown(wait=False, cancel_futures=True)
diff --git a/usr/share/biglinux/bigcam/utils/atomic_json.py b/usr/share/biglinux/bigcam/utils/atomic_json.py
new file mode 100644
index 0000000..af989db
--- /dev/null
+++ b/usr/share/biglinux/bigcam/utils/atomic_json.py
@@ -0,0 +1,83 @@
+"""Small durable JSON store shared by settings and profiles (Linux/POSIX)."""
+from __future__ import annotations
+
+from contextlib import contextmanager
+import fcntl
+import json
+import os
+from pathlib import Path
+import stat
+import tempfile
+from typing import Iterator
+
+MAX_JSON_BYTES = 1_048_576
+
+
+def read_object(path: str | Path) -> dict:
+ """Read an object, rejecting symlinks, oversized files and invalid roots."""
+ path = os.fspath(path)
+ try:
+ fd = os.open(path, os.O_RDONLY | os.O_CLOEXEC | os.O_NOFOLLOW | os.O_NONBLOCK)
+ except FileNotFoundError:
+ return {}
+ with os.fdopen(fd, "r", encoding="utf-8") as stream:
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
+ raise ValueError("Configuration is not a regular file")
+ raw = stream.read(MAX_JSON_BYTES + 1)
+ if len(raw.encode("utf-8")) > MAX_JSON_BYTES:
+ raise ValueError("Configuration exceeds the size limit")
+ data = json.loads(raw, parse_constant=_invalid_number)
+ if not isinstance(data, dict):
+ raise ValueError("Configuration root must be an object")
+ return data
+
+
+def _invalid_number(value: str):
+ raise ValueError(f"Non-finite JSON number: {value}")
+
+
+@contextmanager
+def locked(path: str | Path) -> Iterator[None]:
+ """Serialize read-modify-replace across independent instances/processes.
+
+ The lock is a separate, stable inode: replacing the data file must not replace
+ the lock. Do not delete lock files while another process may be using them.
+ """
+ lock_path = os.fspath(path) + ".lock"
+ fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_CLOEXEC | os.O_NOFOLLOW, 0o600)
+ try:
+ if not stat.S_ISREG(os.fstat(fd).st_mode):
+ raise ValueError("Lock is not a regular file")
+ fcntl.flock(fd, fcntl.LOCK_EX)
+ yield
+ finally:
+ os.close(fd)
+
+
+def write_object(path: str | Path, data: dict) -> None:
+ """Durably replace a JSON object with private mode, without truncating it."""
+ path = Path(path)
+ if not isinstance(data, dict):
+ raise ValueError("Configuration root must be an object")
+ serialized = json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
+ if len(serialized.encode("utf-8")) > MAX_JSON_BYTES:
+ raise ValueError("Configuration exceeds the size limit")
+ if path.is_symlink():
+ raise ValueError("Refusing to replace a symbolic link")
+ fd, temporary = tempfile.mkstemp(prefix=".bigcam-", suffix=".tmp", dir=path.parent)
+ try:
+ with os.fdopen(fd, "w", encoding="utf-8") as stream:
+ stream.write(serialized)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path)
+ directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC)
+ try:
+ os.fsync(directory)
+ finally:
+ os.close(directory)
+ finally:
+ try:
+ os.unlink(temporary)
+ except FileNotFoundError:
+ pass
diff --git a/usr/share/biglinux/bigcam/utils/command_runner.py b/usr/share/biglinux/bigcam/utils/command_runner.py
index e537f3c..4e4d74b 100644
--- a/usr/share/biglinux/bigcam/utils/command_runner.py
+++ b/usr/share/biglinux/bigcam/utils/command_runner.py
@@ -1,71 +1,33 @@
-"""Secure subprocess command runner with standardized timeouts and typing."""
+"""Subprocess boundaries with finite timeouts and credential-free diagnostics."""
+from __future__ import annotations
-import subprocess
import logging
-from typing import List, Optional, Tuple, IO, Any
+import subprocess
+from typing import Any
log = logging.getLogger(__name__)
-class SecureCommandRunner:
- """Wrapper around subprocess to ensure safety, logging, and strict typing."""
+def _arguments(args) -> list[str]:
+ if not isinstance(args, (list, tuple)) or not args or any(not isinstance(a, str) or "\0" in a for a in args):
+ raise ValueError("Expected a nonempty argument vector of NUL-free strings")
+ return list(args)
+
+
+class SecureCommandRunner:
@staticmethod
- def run_safe(
- args: List[str],
- timeout: int = 5,
- capture_output: bool = True,
- check: bool = False,
- **kwargs: Any
- ) -> subprocess.CompletedProcess[bytes]:
- """
- Executes a subprocess securely.
-
- Args:
- args: Command and arguments as a strict list of strings.
- timeout: Maximum execution time in seconds.
- capture_output: Whether to capture stdout/stderr.
- check: Whether to raise CalledProcessError on non-zero exit status.
- **kwargs: Extra arguments passed to subprocess.run.
-
- Returns:
- subprocess.CompletedProcess
-
- Raises:
- subprocess.TimeoutExpired: If the command times out.
- FileNotFoundError: If the binary is not found.
- subprocess.CalledProcessError: If check=True and exit status is non-zero.
- """
- # Enforce shell=False for security against injection attacks
+ def run_safe(args: list[str], timeout: float = 5, capture_output: bool = True,
+ check: bool = False, **kwargs: Any) -> subprocess.CompletedProcess:
+ args = _arguments(args)
kwargs["shell"] = False
-
if capture_output:
kwargs["capture_output"] = True
-
- log.debug(f"Running secure command: {' '.join(args)}")
-
+ log.debug("Running %s (%d arguments)", args[0], len(args) - 1)
return subprocess.run(args, timeout=timeout, check=check, **kwargs)
@staticmethod
- def popen_safe(
- args: List[str],
- stdout: Optional[int | IO[Any]] = None,
- stderr: Optional[int | IO[Any]] = None,
- **kwargs: Any
- ) -> subprocess.Popen[bytes]:
- """
- Starts a background subprocess securely.
-
- Args:
- args: Command and arguments as a strict list of strings.
- stdout: Output stream destination.
- stderr: Error stream destination.
- **kwargs: Extra arguments passed to subprocess.Popen.
-
- Returns:
- subprocess.Popen
- """
+ def popen_safe(args: list[str], stdout=None, stderr=None, **kwargs: Any) -> subprocess.Popen:
+ args = _arguments(args)
kwargs["shell"] = False
-
- log.debug(f"Starting secure background process: {' '.join(args)}")
-
+ log.debug("Starting %s (%d arguments)", args[0], len(args) - 1)
return subprocess.Popen(args, stdout=stdout, stderr=stderr, **kwargs)
diff --git a/usr/share/biglinux/bigcam/utils/frame_buffers.py b/usr/share/biglinux/bigcam/utils/frame_buffers.py
new file mode 100644
index 0000000..207d6c2
--- /dev/null
+++ b/usr/share/biglinux/bigcam/utils/frame_buffers.py
@@ -0,0 +1,43 @@
+"""Packed video layout and bounded latest-value handoff, independent of GTK."""
+from __future__ import annotations
+import threading
+import numpy as np
+
+
+def bgr_from_bgra(data, width: int, height: int, stride: int, offset: int = 0) -> np.ndarray:
+ if width <= 0 or height <= 0 or stride < width * 4 or offset < 0:
+ raise ValueError("Invalid packed video layout")
+ required = offset + (height - 1) * stride + width * 4
+ if required > len(data):
+ raise ValueError("Video buffer is shorter than its declared layout")
+ # Copy before the GStreamer mapping is released; do not retain a borrowed view.
+ return np.ndarray((height, width, 4), dtype=np.uint8, buffer=data,
+ offset=offset, strides=(stride, 4, 1))[:, :, :3].copy()
+
+
+class LatestValue:
+ """At most one pending callback and one owned value, regardless of producer FPS."""
+ def __init__(self):
+ self._lock = threading.Lock()
+ self._value = None
+ self._scheduled = False
+
+ def publish(self, value) -> bool:
+ with self._lock:
+ self._value = value
+ if self._scheduled:
+ return False
+ self._scheduled = True
+ return True
+
+ def take(self):
+ with self._lock:
+ value, self._value = self._value, None
+ self._scheduled = False
+ return value
+
+ def clear(self):
+ # Keep the scheduled flag until the existing idle callback consumes it.
+ # Otherwise a stop/start could schedule two callbacks for the same slot.
+ with self._lock:
+ self._value = None
diff --git a/usr/share/biglinux/bigcam/utils/gst_buffers.py b/usr/share/biglinux/bigcam/utils/gst_buffers.py
new file mode 100644
index 0000000..664a458
--- /dev/null
+++ b/usr/share/biglinux/bigcam/utils/gst_buffers.py
@@ -0,0 +1,20 @@
+"""Create owned Gst video buffers with explicit packed BGR row layout."""
+import numpy as np
+import gi
+gi.require_version("Gst", "1.0")
+gi.require_version("GstVideo", "1.0")
+from gi.repository import Gst, GstVideo
+
+
+def bgr_buffer(frame):
+ frame = np.ascontiguousarray(frame, dtype=np.uint8)
+ if frame.ndim != 3 or frame.shape[2] != 3:
+ raise ValueError("Expected a three-channel BGR image")
+ height, width = frame.shape[:2]
+ if not height or not width:
+ raise ValueError("Empty BGR image")
+ buffer = Gst.Buffer.new_wrapped(frame.tobytes())
+ GstVideo.buffer_add_video_meta_full(buffer, GstVideo.VideoFrameFlags.NONE,
+ GstVideo.VideoFormat.BGR, width, height, 1,
+ [0, 0, 0, 0], [width * 3, 0, 0, 0])
+ return buffer
diff --git a/usr/share/biglinux/bigcam/utils/i18n.py b/usr/share/biglinux/bigcam/utils/i18n.py
index 776b8a7..22fa4c4 100644
--- a/usr/share/biglinux/bigcam/utils/i18n.py
+++ b/usr/share/biglinux/bigcam/utils/i18n.py
@@ -1,25 +1,19 @@
+"""gettext domain used by Python, including plural and contextual messages."""
import gettext
-import os
import locale
+import os
APP_NAME = "bigcam"
-
-# When installed: /usr/share/locale
-# When developing: /usr/share/locale
_app_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_share_dir = os.path.dirname(os.path.dirname(_app_dir))
localedir = os.path.join(_share_dir, "locale")
-
try:
locale.setlocale(locale.LC_ALL, "")
except locale.Error:
pass
-
-if os.path.isdir(localedir):
- gettext.bindtextdomain(APP_NAME, localedir)
- gettext.textdomain(APP_NAME)
- _ = gettext.gettext
-else:
-
- def _(msg):
- return msg
+gettext.bindtextdomain(APP_NAME, localedir)
+gettext.textdomain(APP_NAME)
+_translation = gettext.translation(APP_NAME, localedir=localedir, fallback=True)
+_ = _translation.gettext
+ngettext = _translation.ngettext
+pgettext = _translation.pgettext
diff --git a/usr/share/biglinux/bigcam/utils/latest_commands.py b/usr/share/biglinux/bigcam/utils/latest_commands.py
new file mode 100644
index 0000000..1d2c21b
--- /dev/null
+++ b/usr/share/biglinux/bigcam/utils/latest_commands.py
@@ -0,0 +1,56 @@
+"""Serial, latest-value-wins camera commands with explicit invalidation."""
+import threading
+from collections import OrderedDict
+from gi.repository import GLib
+from utils.async_worker import run_async
+
+
+class LatestCommands:
+ def __init__(self):
+ self._lock = threading.Lock()
+ self._pending = OrderedDict()
+ self._running = False
+ self._generation = 0
+
+ def invalidate(self):
+ with self._lock:
+ self._generation += 1
+ self._pending.clear()
+
+ def submit(self, key, task, success, failure):
+ with self._lock:
+ self._pending[key] = (self._generation, task, success, failure)
+ if self._running:
+ return
+ self._running = True
+ run_async(self._drain, on_error=self._submit_failed)
+
+ def _submit_failed(self, error):
+ with self._lock:
+ pending = list(self._pending.values())
+ self._pending.clear()
+ self._running = False
+ for generation, _task, _success, failure in pending:
+ self._deliver(generation, failure, error)
+
+ def _deliver(self, generation, callback, value):
+ with self._lock:
+ valid = generation == self._generation
+ if valid and callback:
+ callback(value)
+ return GLib.SOURCE_REMOVE
+
+ def _drain(self):
+ while True:
+ with self._lock:
+ if not self._pending:
+ self._running = False
+ return
+ _key, (generation, task, success, failure) = self._pending.popitem(last=False)
+ if generation != self._generation:
+ continue
+ try:
+ value = task()
+ GLib.idle_add(self._deliver, generation, success, value)
+ except Exception as error:
+ GLib.idle_add(self._deliver, generation, failure, error)
diff --git a/usr/share/biglinux/bigcam/utils/media_paths.py b/usr/share/biglinux/bigcam/utils/media_paths.py
new file mode 100644
index 0000000..7d8917e
--- /dev/null
+++ b/usr/share/biglinux/bigcam/utils/media_paths.py
@@ -0,0 +1,24 @@
+"""Exclusive, collision-free output allocation for captures and recordings."""
+from datetime import datetime
+import os
+from pathlib import Path
+import tempfile
+
+
+def reserve_media_path(directory: str, suffix: str, prefix: str = "bigcam_") -> str:
+ if not suffix.startswith(".") or "/" in suffix or "/" in prefix:
+ raise ValueError("Invalid media filename components")
+ Path(directory).mkdir(parents=True, exist_ok=True)
+ stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
+ fd, path = tempfile.mkstemp(prefix=prefix + stamp + "_", suffix=suffix, dir=directory)
+ os.close(fd)
+ return path
+
+
+def reserve_named_path(directory: str, filename: str) -> str:
+ if not filename or Path(filename).name != filename or filename in {".", ".."}:
+ raise ValueError("Filename must be a basename")
+ path = Path(directory) / filename
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
+ os.close(fd)
+ return str(path)
diff --git a/usr/share/biglinux/bigcam/utils/settings_manager.py b/usr/share/biglinux/bigcam/utils/settings_manager.py
index c4bb2f5..7e20ac3 100644
--- a/usr/share/biglinux/bigcam/utils/settings_manager.py
+++ b/usr/share/biglinux/bigcam/utils/settings_manager.py
@@ -1,12 +1,16 @@
-"""JSON-based settings persistence for BigCam."""
+"""Atomic, process-safe settings with independent-instance merge semantics."""
+from __future__ import annotations
-import json
+from copy import deepcopy
import logging
+import math
import os
-import tempfile
+from pathlib import Path
import threading
+import uuid
from utils import xdg
+from utils.atomic_json import locked, read_object, write_object
log = logging.getLogger(__name__)
@@ -40,7 +44,7 @@
"hotplug_enabled": True,
"last-camera-id": "",
# Virtual camera
- "virtual-camera-enabled": True,
+ "virtual-camera-enabled": False,
"vcam-max-devices": 5,
"vcam-name-template": "BigCam Virtual",
"vcam-disabled-cameras": [], # List of camera IDs where vcam is explicitly disabled
@@ -53,84 +57,128 @@
"recording-video-bitrate": 8000,
# IP Cameras (list serialised as JSON array)
"ip_cameras": [],
+ "reduce-motion": False,
+ "auto-hide-controls": True,
+ "resource-monitor-auto-optimize": False,
# Resource monitor
"resource-monitor-enabled": False,
"resource-warnings-dismissed": [],
}
-_BOOL_TRUE = {"true", "1", "yes"}
-_BOOL_FALSE = {"false", "0", "no", ""}
+
+# Clamp persisted settings as well as values arriving through the UI.
+_RANGES = {
+ "window-width": (320, 16384), "window-height": (240, 16384),
+ "sidebar-position": (200, 1200), "fps-limit": (0, 240),
+ "capture-timer": (0, 60), "overlay-opacity": (0, 100),
+ "controls-opacity": (20, 100), "window-opacity": (0, 100),
+ "vcam-max-devices": (1, 8), "recording-video-bitrate": (500, 50000),
+}
+
+
+def _coerce(key: str, value: object, fallback: object) -> object:
+ if isinstance(fallback, bool):
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ word = value.strip().lower()
+ if word in {"true", "1", "yes"}:
+ return True
+ if word in {"false", "0", "no", ""}:
+ return False
+ if isinstance(value, (int, float)) and value in (0, 1):
+ return bool(value)
+ return fallback
+ if isinstance(fallback, (int, float)):
+ try:
+ number = float(value)
+ if not math.isfinite(number):
+ return fallback
+ result = int(number) if isinstance(fallback, int) else number
+ except (ValueError, TypeError, OverflowError):
+ return fallback
+ if key in _RANGES:
+ low, high = _RANGES[key]
+ result = max(low, min(high, result))
+ return result
+ if isinstance(fallback, (list, dict)):
+ return deepcopy(value if isinstance(value, type(fallback)) else fallback)
+ choices = {
+ "theme": {"system", "light", "dark"},
+ "recording-video-codec": {"h264", "h265", "vp9", "mjpeg"},
+ "recording-audio-codec": {"opus", "aac", "mp3", "vorbis"},
+ "recording-container": {"mkv", "mp4", "webm"},
+ "preferred-resolution": {"", "480", "720", "1080", "2160"},
+ }
+ if key in choices and (not isinstance(value, str) or value not in choices[key]):
+ return deepcopy(fallback)
+ return value if isinstance(value, str) else deepcopy(fallback)
class SettingsManager:
- """Thread-safe JSON settings backed by ~/.config/bigcam/settings.json."""
+ """Merge each write against the latest file under an advisory file lock.
+
+ Readers invalidate their cache when the file inode/mtime/size changes. Lists
+ and dictionaries returned to callers are copies, never shared mutable state.
+ I/O failure is logged and returned by set(); the last good file is retained.
+ """
def __init__(self) -> None:
self._path = os.path.join(xdg.config_dir(), "settings.json")
self._data: dict[str, object] = {}
- self._lock = threading.Lock()
+ self._signature = None
+ self._lock = threading.RLock()
self._load()
- # -- public API ----------------------------------------------------------
+ def _stat_signature(self):
+ try:
+ st = os.stat(self._path, follow_symlinks=False)
+ return st.st_ino, st.st_size, st.st_mtime_ns
+ except OSError:
+ return None
+
+ def _load(self) -> None:
+ with self._lock:
+ signature = self._stat_signature()
+ try:
+ self._data = read_object(self._path)
+ except (OSError, ValueError, UnicodeError):
+ log.warning("Invalid settings; using defaults", exc_info=True)
+ self._data = {}
+ self._signature = signature
def get(self, key: str, default: object = None) -> object:
with self._lock:
+ if self._stat_signature() != self._signature:
+ self._load()
fallback = default if default is not None else _DEFAULTS.get(key, "")
- value = self._data.get(key, fallback)
- # coerce to the same type as the fallback
- if isinstance(fallback, bool):
- if isinstance(value, bool):
- return value
- if isinstance(value, str):
- low = value.lower()
- if low in _BOOL_TRUE:
- return True
- if low in _BOOL_FALSE:
- return False
- return bool(value)
- if isinstance(fallback, int):
- try:
- return int(value)
- except (ValueError, TypeError):
- return fallback
- if isinstance(fallback, float):
- try:
- return float(value)
- except (ValueError, TypeError):
- return fallback
- if isinstance(fallback, list):
- return value if isinstance(value, list) else fallback
- return str(value) if value is not None else ""
-
- def set(self, key: str, value: object) -> None:
- with self._lock:
- self._data[key] = value
- self._save()
+ return _coerce(key, self._data.get(key, fallback), fallback)
- # -- persistence ---------------------------------------------------------
+ def set(self, key: str, value: object) -> bool:
+ return self.update({key: value})
- def _load(self) -> None:
+ def update(self, changes: dict[str, object]) -> bool:
+ """Apply multiple values in one transaction, without losing other keys."""
+ if any(not isinstance(key, str) for key in changes):
+ raise TypeError("Setting names must be strings")
+ changes = deepcopy(changes)
with self._lock:
- if not os.path.isfile(self._path):
- self._data = {}
- return
- try:
- with open(self._path, "r", encoding="utf-8") as fh:
- self._data = json.load(fh)
- except Exception:
- log.warning("Failed to load settings from %s", self._path, exc_info=True)
- self._data = {}
-
- def _save(self) -> None:
- try:
- dir_path = os.path.dirname(self._path)
- fd, tmp = tempfile.mkstemp(dir=dir_path, suffix=".tmp")
try:
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
- json.dump(self._data, fh, indent=2, ensure_ascii=False)
- os.replace(tmp, self._path)
- except BaseException:
- os.unlink(tmp)
- raise
- except Exception as exc:
- log.error("Settings save error: %s", exc)
+ with locked(self._path):
+ try:
+ latest = read_object(self._path)
+ except (ValueError, UnicodeError):
+ # Preserve invalid user data for diagnosis before recovery.
+ path = Path(self._path)
+ if path.exists() and not path.is_symlink():
+ os.replace(path, path.with_name(f"settings.invalid-{uuid.uuid4().hex}.json"))
+ latest = {}
+ for key, value in changes.items():
+ latest[key] = _coerce(key, value, _DEFAULTS[key]) if key in _DEFAULTS else value
+ write_object(self._path, latest)
+ self._data = latest
+ self._signature = self._stat_signature()
+ return True
+ except (OSError, ValueError, TypeError):
+ log.exception("Failed to save settings")
+ return False
diff --git a/usr/share/biglinux/bigcam/utils/urls.py b/usr/share/biglinux/bigcam/utils/urls.py
new file mode 100644
index 0000000..485ee3a
--- /dev/null
+++ b/usr/share/biglinux/bigcam/utils/urls.py
@@ -0,0 +1,34 @@
+"""Network camera URLs and GStreamer property quoting (not shell quoting)."""
+from __future__ import annotations
+
+import hashlib
+from urllib.parse import urlsplit, urlunsplit
+
+_ALLOWED = {"rtsp", "rtsps", "http", "https"}
+
+
+def camera_url(value: str) -> str:
+ if not isinstance(value, str) or len(value) > 8192 or any(ord(c) < 32 or ord(c) == 127 for c in value):
+ raise ValueError("Invalid camera URL")
+ parts = urlsplit(value.strip())
+ if parts.scheme.lower() not in _ALLOWED or not parts.hostname:
+ raise ValueError("Use an HTTP(S) or RTSP(S) URL with a host")
+ # Access validates malformed and out-of-range ports.
+ _ = parts.port
+ return urlunsplit((parts.scheme.lower(), parts.netloc, parts.path, parts.query, ""))
+
+
+def public_camera_name(value: str) -> str:
+ parts = urlsplit(camera_url(value))
+ # Deliberately omit user info, path, query and fragment; these may contain secrets.
+ return f"{parts.scheme}://{parts.hostname}"
+
+
+def camera_url_id(value: str) -> str:
+ return "ip:" + hashlib.sha256(camera_url(value).encode("utf-8")).hexdigest()
+
+
+def gst_quote(value: str) -> str:
+ if not isinstance(value, str) or any(ord(c) < 32 for c in value):
+ raise ValueError("Invalid GStreamer property string")
+ return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
diff --git a/usr/share/biglinux/bigcam/utils/video_formats.py b/usr/share/biglinux/bigcam/utils/video_formats.py
new file mode 100644
index 0000000..d08baeb
--- /dev/null
+++ b/usr/share/biglinux/bigcam/utils/video_formats.py
@@ -0,0 +1,38 @@
+"""Frame-rate and device-format contracts shared by capture backends."""
+from fractions import Fraction
+import math
+
+
+def frame_rate(value: float) -> str:
+ value = float(value)
+ if not math.isfinite(value) or value <= 0 or value > 1000:
+ raise ValueError("Invalid frame rate")
+ # v4l2-ctl displays rounded decimals for NTSC frame intervals.
+ for numerator in (24000, 30000, 60000, 120000):
+ if abs(value - numerator / 1001) < 0.005:
+ return f"{numerator}/1001"
+ rate = Fraction(str(value)).limit_denominator(100000)
+ return f"{rate.numerator}/{rate.denominator}"
+
+
+def source_caps(fmt) -> tuple[str, str]:
+ """Return caps and decoder for a supported V4L2 FOURCC, without guessing raw."""
+ compressed = {"MJPG": ("image/jpeg", "jpegdec"), "JPEG": ("image/jpeg", "jpegdec"),
+ "H264": ("video/x-h264", "h264parse ! decodebin"),
+ "HEVC": ("video/x-h265", "h265parse ! decodebin"),
+ "H265": ("video/x-h265", "h265parse ! decodebin")}
+ raw = {"YUYV": "YUY2", "YUY2": "YUY2", "UYVY": "UYVY", "NV12": "NV12",
+ "NV21": "NV21", "YU12": "I420", "YV12": "YV12", "RGB3": "RGB",
+ "BGR3": "BGR", "GREY": "GRAY8", "RGBx": "RGBx", "BGRx": "BGRx"}
+ if fmt.pixel_format in compressed:
+ caps, decoder = compressed[fmt.pixel_format]
+ elif fmt.pixel_format in raw:
+ caps, decoder = "video/x-raw,format=" + raw[fmt.pixel_format], ""
+ else:
+ raise ValueError(f"Unsupported camera pixel format: {fmt.pixel_format}")
+ if fmt.width <= 0 or fmt.height <= 0:
+ raise ValueError("Invalid frame dimensions")
+ caps += f",width={fmt.width},height={fmt.height}"
+ if fmt.fps:
+ caps += ",framerate=" + frame_rate(max(fmt.fps))
+ return caps, decoder
diff --git a/usr/share/biglinux/bigcam/web/audio-worklet.js b/usr/share/biglinux/bigcam/web/audio-worklet.js
new file mode 100644
index 0000000..8e3bb79
--- /dev/null
+++ b/usr/share/biglinux/bigcam/web/audio-worklet.js
@@ -0,0 +1,32 @@
+"use strict";
+// Resample the actual browser sample rate, retaining phase across variable-size
+// render quanta. Emit 20 ms S16LE/mono packets; never assume a 128-sample block.
+class BigCamPCM extends AudioWorkletProcessor {
+ constructor() {
+ super();
+ this.phase = 0; this.sum = 0; this.count = 0;
+ this.output = new ArrayBuffer(640); this.view = new DataView(this.output); this.offset = 0;
+ }
+ process(inputs) {
+ const channels = inputs[0];
+ if (!channels || !channels.length || !channels[0].length) return true;
+ const ratio = 16000 / sampleRate;
+ for (let i = 0; i < channels[0].length; ++i) {
+ let sample = 0;
+ for (const channel of channels) sample += channel[i] || 0;
+ this.sum += sample / channels.length; ++this.count;
+ this.phase += ratio;
+ while (this.phase >= 1) {
+ const value = this.count ? this.sum / this.count : sample / channels.length;
+ this.view.setInt16(this.offset, Math.max(-32768, Math.min(32767, Math.round(value * 32768))), true);
+ this.offset += 2; this.phase -= 1; this.sum = 0; this.count = 0;
+ if (this.offset === 640) {
+ this.port.postMessage(this.output, [this.output]);
+ this.output = new ArrayBuffer(640); this.view = new DataView(this.output); this.offset = 0;
+ }
+ }
+ }
+ return true;
+ }
+}
+registerProcessor("bigcam-pcm", BigCamPCM);
diff --git a/usr/share/biglinux/bigcam/web/phone.html b/usr/share/biglinux/bigcam/web/phone.html
new file mode 100644
index 0000000..a0446c0
--- /dev/null
+++ b/usr/share/biglinux/bigcam/web/phone.html
@@ -0,0 +1,22 @@
+
+
+
+BigCamBigCam
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/usr/share/biglinux/bigcam/web/phone.js b/usr/share/biglinux/bigcam/web/phone.js
new file mode 100644
index 0000000..86105b4
--- /dev/null
+++ b/usr/share/biglinux/bigcam/web/phone.js
@@ -0,0 +1,161 @@
+"use strict";
+const config = JSON.parse(document.getElementById("config").textContent);
+const text = key => config.strings[key] || key;
+for (const node of document.querySelectorAll("[data-i18n]")) node.textContent = text(node.dataset.i18n);
+for (const node of document.querySelectorAll("[data-label]")) node.setAttribute("aria-label", text(node.dataset.label));
+const $ = id => document.getElementById(id);
+const query = new URLSearchParams(location.search);
+query.set("client", crypto.randomUUID());
+const suffix = "?" + query;
+let stream, ws, wt, timer, audioContext, worklet, audioSource;
+let generation = 0, busy = false, sending = false, audioSending = false, http = false, audioSequence = 0;
+let quality = 0.75, sent = 0, since = performance.now();
+const status = key => { $("status").textContent = text(key); };
+function buttons(active) {
+ $("start").hidden = active;
+ $("stop").hidden = !active;
+ $("switch").hidden = !active;
+ $("start").disabled = busy;
+}
+function withTimeout(promise, milliseconds) {
+ let timer;
+ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error("timeout")), milliseconds); })])
+ .finally(() => clearTimeout(timer));
+}
+async function websocket() {
+ const socket = new WebSocket("wss://" + location.host + "/ws" + suffix);
+ socket.binaryType = "arraybuffer";
+ try {
+ await withTimeout(new Promise((resolve, reject) => {
+ socket.onopen = resolve;
+ socket.onerror = reject;
+ socket.onclose = reject;
+ }), 5000);
+ socket.onclose = () => { if (socket === ws && stream) { stop(); status("error"); } };
+ return socket;
+ } catch (error) { socket.close(); throw error; }
+}
+async function send(packet, current) {
+ if (current !== generation || !stream) return;
+ if (wt) {
+ const channel = await wt.createUnidirectionalStream();
+ const writer = channel.getWriter();
+ try { await writer.write(packet); await writer.close(); }
+ catch (error) { await writer.abort().catch(() => {}); throw error; }
+ finally { writer.releaseLock(); }
+ } else if (ws && ws.readyState === WebSocket.OPEN) {
+ if (ws.bufferedAmount < 131072) ws.send(packet);
+ } else if (http) {
+ const response = await fetch("/frame" + suffix, {method: "POST", body: packet, signal: AbortSignal.timeout(4000)});
+ if (!response.ok) throw new Error("HTTP " + response.status);
+ } else throw new Error("transport closed");
+}
+async function start() {
+ if (busy || stream) return;
+ busy = true;
+ const current = ++generation;
+ buttons(false); status("connecting");
+ try {
+ const auth = await fetch("/status" + suffix, {signal: AbortSignal.timeout(4000)});
+ if (auth.status === 401 || auth.status === 403) { status("authentication"); throw new Error("authentication"); }
+ if (!auth.ok || (await auth.json()).busy) throw new Error("busy");
+ const height = Number($("resolution").value);
+ const video = {facingMode: {ideal: $("facing").value}};
+ if (height) { video.height = {ideal: height}; video.width = {ideal: Math.round(height * 16 / 9)}; }
+ const acquired = await navigator.mediaDevices.getUserMedia({video, audio: $("microphone").checked});
+ if (current !== generation) { acquired.getTracks().forEach(track => track.stop()); return; }
+ stream = acquired; $("video").srcObject = stream;
+ await $("video").play();
+ http = false;
+ if (config.quic && typeof WebTransport !== "undefined") {
+ const hash = Uint8Array.from(atob(config.certHash), character => character.charCodeAt(0));
+ const transport = new WebTransport("https://" + location.host + "/camera" + suffix,
+ {serverCertificateHashes: [{algorithm: "sha-256", value: hash.buffer}]});
+ try { await withTimeout(transport.ready, 3500); wt = transport; }
+ catch (_) { transport.close(); }
+ }
+ if (!wt) {
+ try { ws = await websocket(); }
+ catch (_) {
+ // Recheck authentication. A rejected token must never trigger fallback media.
+ const check = await fetch("/status" + suffix, {signal: AbortSignal.timeout(4000)});
+ if (!check.ok) throw new Error("authentication");
+ http = true;
+ }
+ }
+ if (current !== generation) return;
+ quality = Number($("quality").value);
+ sending = audioSending = false; audioSequence = 0;
+ if ($("microphone").checked) await audio(current);
+ status("connected"); buttons(true);
+ timer = setInterval(() => capture(current), 1000 / Number($("fps").value));
+ } catch (error) {
+ await stop(); status(error.message === "authentication" ? "authentication" : "error");
+ } finally { busy = false; $("start").disabled = false; }
+}
+async function capture(current) {
+ if (current !== generation || sending || !stream || !$("video").videoWidth) return;
+ if (ws && ws.bufferedAmount > 131072) return;
+ sending = true;
+ try {
+ const canvas = $("canvas"), video = $("video");
+ canvas.width = video.videoWidth; canvas.height = video.videoHeight;
+ canvas.getContext("2d").drawImage(video, 0, 0);
+ const base = Number($("quality").value);
+ quality = ws && ws.bufferedAmount > 65536 ? Math.max(.3, quality - .05) : Math.min(base, quality + .02);
+ const blob = await new Promise(resolve => canvas.toBlob(resolve, "image/jpeg", quality));
+ if (blob && current === generation) {
+ await withTimeout(send(new Uint8Array(await blob.arrayBuffer()), current), 5000);
+ ++sent;
+ if (performance.now() - since >= 1000) {
+ $("stats").textContent = `${canvas.width} × ${canvas.height} · ${Math.round(sent * 1000 / (performance.now() - since))} fps · ${wt ? "QUIC" : http ? "HTTPS" : "WS"}`;
+ sent = 0; since = performance.now();
+ }
+ }
+ } catch (_) { if (current === generation) { await stop(); status("error"); } }
+ finally { sending = false; }
+}
+async function audio(current) {
+ audioContext = new AudioContext();
+ await audioContext.audioWorklet.addModule("/audio-worklet.js" + suffix);
+ if (current !== generation) return;
+ audioSource = audioContext.createMediaStreamSource(stream);
+ worklet = new AudioWorkletNode(audioContext, "bigcam-pcm");
+ const silence = audioContext.createGain(); silence.gain.value = 0;
+ audioSource.connect(worklet).connect(silence).connect(audioContext.destination);
+ worklet.port.onmessage = async event => {
+ if (current !== generation || audioSending) return;
+ audioSending = true;
+ const packet = new Uint8Array(645);
+ packet[0] = 1; new DataView(packet.buffer).setUint32(1, audioSequence++);
+ packet.set(new Uint8Array(event.data), 5);
+ try { await withTimeout(send(packet, current), 2000); }
+ catch (_) { if (current === generation) status("audioError"); }
+ finally { audioSending = false; }
+ };
+ await audioContext.resume();
+}
+async function stop() {
+ ++generation;
+ if (timer) clearInterval(timer);
+ timer = null;
+ if (worklet) { worklet.port.onmessage = null; worklet.disconnect(); worklet = null; }
+ if (audioSource) { audioSource.disconnect(); audioSource = null; }
+ if (audioContext) { const context = audioContext; audioContext = null; await context.close().catch(() => {}); }
+ if (wt) { wt.close(); wt = null; }
+ if (ws) { const socket = ws; ws = null; socket.close(); }
+ if (http) { fetch("/disconnect" + suffix, {method:"POST", keepalive:true}).catch(() => {}); http = false; }
+ if (stream) { stream.getTracks().forEach(track => track.stop()); stream = null; }
+ $("video").srcObject = null; $("stats").textContent = "";
+ buttons(false); status("disconnected");
+}
+$("start").addEventListener("click", start);
+$("stop").addEventListener("click", stop);
+$("switch").addEventListener("click", async () => {
+ $("facing").value = $("facing").value === "user" ? "environment" : "user";
+ await stop(); await start();
+});
+for (const id of ["resolution", "facing", "fps", "microphone"]) {
+ $(id).addEventListener("change", async () => { if (stream) { await stop(); await start(); } });
+}
+window.addEventListener("pagehide", () => { stop(); });
diff --git a/usr/share/locale/bigcam.pot b/usr/share/locale/bigcam.pot
index 769ccd7..789eade 100644
--- a/usr/share/locale/bigcam.pot
+++ b/usr/share/locale/bigcam.pot
@@ -6,9 +6,9 @@
#, fuzzy
msgid ""
msgstr ""
-"Project-Id-Version: bigcam 1.0\n"
+"Project-Id-Version: bigcam\n"
"Report-Msgid-Bugs-To: \n"
-"POT-Creation-Date: 2026-03-26 14:24-0300\n"
+"POT-Creation-Date: 2026-09-07 23:36-0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME \n"
"Language-Team: LANGUAGE \n"
@@ -16,2372 +16,2670 @@ msgstr ""
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n"
-#: usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py:232
-msgid "Generic Camera"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:377
+#, python-format
+msgid "%(source)s — audio %(index)d"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/libcamera_backend.py:76
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:20
-#: usr/share/biglinux/bigcam/core/effects.py:251
-msgid "Brightness"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:271
+#, python-format
+msgid "%d file could not be moved to Trash."
+msgid_plural "%d files could not be moved to Trash."
+msgstr[0] ""
+msgstr[1] ""
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:227
+#, python-format
+msgid "%d item selected"
+msgid_plural "%d items selected"
+msgstr[0] ""
+msgstr[1] ""
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:735
+#, python-format
+msgid "%d second remaining"
+msgid_plural "%d seconds remaining"
+msgstr[0] ""
+msgstr[1] ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:870
+#, python-format
+msgid "%d virtual camera allocated"
+msgid_plural "%d virtual cameras allocated"
+msgstr[0] ""
+msgstr[1] ""
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:408
+#, python-format
+msgid "%s — volume"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/libcamera_backend.py:87
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:21
-#: usr/share/biglinux/bigcam/core/effects.py:252
-msgid "Contrast"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:608
+msgid "180°"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/libcamera_backend.py:98
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:22
-msgid "Saturation"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:49
+msgid "3A Lock"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/libcamera_backend.py:109
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:26
-#: usr/share/biglinux/bigcam/core/effects.py:285
-msgid "Auto White Balance"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:425
+msgid "6-digit code (shown on phone screen)"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/libcamera_backend.py:127
-msgid "Exposure Mode"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:607
+msgid "90° Left"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:23
-msgid "Hue"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:606
+msgid "90° Right"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:24
-msgid "Sharpness"
+#: usr/share/biglinux/bigcam/ui/window.py:1053
+msgid "About"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:25
-#: usr/share/biglinux/bigcam/core/effects.py:264
-msgid "Gamma"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1004
+msgid "Accept the USB Debugging prompt on the phone screen"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:27
-msgid "White Balance Temperature"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:927
+msgid "Accept the security warning in the browser"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:28
-msgid "Gain"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:416
+msgid "Account"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:29
-msgid "Auto Exposure"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:362
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:80
+msgid "Actions"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:30
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:31
-msgid "Exposure Time"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:980
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:146
+msgid "Active"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:32
-msgid "Exposure Auto Priority"
+#: usr/share/biglinux/bigcam/ui/window.py:2388
+msgid "Active cameras:"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:87
+msgid "Active features that may be causing this:"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:2611
+msgid "Active video recording with encoding"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:60
+msgid "Add Camera"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:24
+msgid "Add IP Camera"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:1049
+msgid "Add IP Camera…"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:64
+msgid "Add IP camera"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:397
+msgid "Address"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:180
+msgid ""
+"Adjust resolution, quality, and FPS directly on the phone's browser page. "
+"Use the Effects tab for brightness, contrast, and other adjustments."
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/effects_page.py:22
+msgid "Adjustments"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:30
+#: usr/share/biglinux/bigcam/ui/effects_page.py:25
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:280
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:470
+msgid "Advanced"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:95
+msgid "Advanced Controls"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:972
+msgid "After pairing, tap 'Scan' to find the device"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:138
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1041
+msgid "AirPlay"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1027
+msgid "AirPlay (iPhone / iPad)"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1214
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1983
+msgid "AirPlay connected"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:568
+msgid ""
+"AirPlay mirrors the iPhone screen. Open the Camera app on your iPhone after "
+"connecting to use it as a webcam."
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:2643
+msgid "AirPlay receiver"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1997
+msgid "AirPlay stopped unexpectedly"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:689
+msgid "All Files"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:276
+msgid "Always on Top"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:2634
+msgid "Android camera via USB/Wi-Fi ADB"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:276
+msgid "App Store"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:2644
+msgid "Apple AirPlay screen mirroring via UxPlay"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:154
+msgid "Application theme"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:85
+msgid ""
+"Apply brightness, contrast, blur,\n"
+"sepia, vignette and more effects live"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/effects_page.py:24
+msgid "Artistic"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:451
+msgid "Audio Codec"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/core/camera_manager.py:230
+msgid "Audio Volume"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:263
+msgid "Authentication (TOTP)"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:11
+#: usr/share/biglinux/bigcam/ui/settings_page.py:357
+#: usr/share/biglinux/bigcam/ui/settings_page.py:380
+msgid "Auto"
msgstr ""
#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:33
+msgid "Auto Exposure"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:37
msgid "Auto Focus"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:34
-msgid "Focus Distance"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:47
+msgid "Auto ISO"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:35
-msgid "Zoom"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:30
+#: usr/share/biglinux/bigcam/core/effects.py:285
+msgid "Auto White Balance"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:36
-msgid "Pan"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:448
+msgid "Auto-fill IP:Port"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:37
-msgid "Tilt"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:204
+#: usr/share/biglinux/bigcam/ui/settings_page.py:212
+msgid "Auto-optimize resources"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:38
-msgid "Power Line Frequency"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:161
+msgid "Automatically detect cameras when plugged or unplugged."
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:39
-msgid "Exposure Bias"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:205
+msgid "Automatically disable heavy background features when usage is high."
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:40
-msgid "WB Preset"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:237
+msgid "Automatically hide camera controls"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:41
-msgid "Image Stabilization"
+#: usr/share/biglinux/bigcam/ui/tools_page.py:109
+msgid "Automatically take a photo when a smile is detected"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:42
-msgid "ISO Sensitivity"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:236
+msgid "Avoid capture flashes and decorative animations."
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:43
-msgid "Auto ISO"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:284
+#: usr/share/biglinux/bigcam/ui/settings_page.py:295
+msgid "Background transparency"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:44
-msgid "Scene Mode"
+#: usr/share/biglinux/bigcam/ui/window.py:2601
+msgid "Background virtual cameras"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:45
-msgid "3A Lock"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:292
+msgid "Barcode"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:46
-msgid "LED Mode"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:291
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:494
+msgid "Bitrate"
msgstr ""
-#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:47
-msgid "LED Frequency"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:24
+#: usr/share/biglinux/bigcam/core/effects.py:251
+msgid "Brightness"
msgstr ""
#: usr/share/biglinux/bigcam/core/effects.py:247
msgid "Brightness / Contrast"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:260
-msgid "Gamma Correction"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:114
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:934
+msgid "Browser"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:919
+msgid "Browser (Android / iPhone)"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:338
+msgid ""
+"Bypass PipeWire and access the camera directly. May fix flickering on some "
+"webcams."
msgstr ""
#: usr/share/biglinux/bigcam/core/effects.py:272
msgid "CLAHE (Adaptive Contrast)"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:276
-msgid "Clip Limit"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:179
+msgid "Calendar Event"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:277
-msgid "Grid Size"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:10
+#: usr/share/biglinux/bigcam/ui/settings_page.py:348
+msgid "Camera"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:295
-msgid "Sharpen"
+#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:42
+msgid "Camera Details"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:299
-#: usr/share/biglinux/bigcam/core/effects.py:311
-#: usr/share/biglinux/bigcam/core/effects.py:376
-msgid "Strength"
+#: usr/share/biglinux/bigcam/ui/window.py:1387
+msgid "Camera PTP streaming failed."
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:307
-msgid "Denoise"
+#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:53
+msgid "Camera URL (RTSP or HTTP)"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:320
-msgid "Grayscale"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:651
+#: usr/share/biglinux/bigcam/ui/preview_area.py:654
+msgid "Camera busy"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:329
-msgid "Sepia"
+#: usr/share/biglinux/bigcam/core/camera_manager.py:144
+msgid "Camera detection failed. Try refreshing the camera list."
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:338
-msgid "Negative"
+#: usr/share/biglinux/bigcam/ui/window.py:1372
+msgid "Camera does not support USB streaming."
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:347
-msgid "Edge Detection"
+#: usr/share/biglinux/bigcam/ui/window.py:1964
+msgid "Camera in use"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:351
-msgid "Threshold 1"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:650
+msgid "Camera in use by:"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:352
-msgid "Threshold 2"
+#: usr/share/biglinux/bigcam/ui/window.py:1721
+msgid "Camera in video mode"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:360
-msgid "Color Map"
+#: usr/share/biglinux/bigcam/ui/window.py:2399
+msgid "Camera is active"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:364
-msgid "Style"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:653
+msgid "Camera is being used"
msgstr ""
-#: usr/share/biglinux/bigcam/core/effects.py:372
-msgid "Vignette"
+#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:46
+msgid "Camera name"
msgstr ""
-#: usr/share/biglinux/bigcam/core/phone_camera.py:560
-msgid "python-aiohttp is not installed"
+#: usr/share/biglinux/bigcam/ui/window.py:1690
+msgid "Camera photo (full resolution)"
msgstr ""
-#: usr/share/biglinux/bigcam/core/phone_camera.py:579
-msgid "Server did not start in time"
+#: usr/share/biglinux/bigcam/ui/window.py:1514
+#: usr/share/biglinux/bigcam/ui/window.py:1523
+msgid "Camera preferences will apply after recording stops."
+msgstr ""
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:13
+msgid "Camera preview"
msgstr ""
-#: usr/share/biglinux/bigcam/core/phone_camera.py:638
+#: usr/share/biglinux/bigcam/ui/camera_selector.py:66
+msgid "Camera selector"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:1341
+msgid "Camera streaming started!"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:421
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:251
+#: usr/share/biglinux/bigcam/ui/window.py:1693
+#: usr/share/biglinux/bigcam/ui/window.py:1734
+#: usr/share/biglinux/bigcam/ui/window.py:2402
+msgid "Cancel"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:28
+msgid "Capture"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:1039
+msgid "Capture Photo"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:917
+msgid "Capture cancelled."
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/tools_page.py:399
+msgid "Capture failed."
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:1363
+msgid "Capture not supported"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/tools_page.py:108
+msgid "Capture on Smile"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:223
+#: usr/share/biglinux/bigcam/ui/preview_area.py:225
+#: usr/share/biglinux/bigcam/ui/window.py:491
+#: usr/share/biglinux/bigcam/ui/window.py:493
+#: usr/share/biglinux/bigcam/ui/window.py:705
+msgid "Capture photo"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:1730
+msgid "Capture preview frame"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:395
+#: usr/share/biglinux/bigcam/ui/window.py:579
+msgid "Capture timer"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/window.py:883
+#: usr/share/biglinux/bigcam/ui/window.py:977
#, python-format
-msgid "Port %d is already in use"
+msgid "Capture timer: %ds"
msgstr ""
-#: usr/share/biglinux/bigcam/core/stream_engine.py:718
-msgid "Failed to start camera streaming process."
+#: usr/share/biglinux/bigcam/ui/window.py:879
+#: usr/share/biglinux/bigcam/ui/window.py:973
+msgid "Capture timer: Off"
msgstr ""
-#: usr/share/biglinux/bigcam/core/stream_engine.py:732
-msgid "Failed to obtain GStreamer source for this camera."
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:33
+msgid "Captured Photos"
msgstr ""
-#: usr/share/biglinux/bigcam/core/stream_engine.py:815
-#: usr/share/biglinux/bigcam/core/stream_engine.py:1143
-#: usr/share/biglinux/bigcam/core/stream_engine.py:1154
-#: usr/share/biglinux/bigcam/core/stream_engine.py:1379
-msgid "Failed to start camera stream."
+#: usr/share/biglinux/bigcam/ui/window.py:1755
+msgid "Capturing photo…"
msgstr ""
-#: usr/share/biglinux/bigcam/core/stream_engine.py:1742
-msgid "Phone camera server not available."
+#: usr/share/biglinux/bigcam/ui/window.py:541
+msgid "Change zoom level"
msgstr ""
-#: usr/share/biglinux/bigcam/core/stream_engine.py:1925
-msgid "Unknown GStreamer error"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:153
+msgid "Check the connection and try again."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/about_dialog.py:39
-msgid ""
-"The universal webcam control center for Linux.\n"
-"\n"
-"BigCam was born as a small shell script so that Rafael Ruscher could use his "
-"Canon Rebel T3 as a webcam during live streams about BigLinux. That humble "
-"hack, written by Rafael and Barnabé di Kartola, evolved from a Bash bridge "
-"between gPhoto2 and FFmpeg into a full GTK4/Adwaita application with live "
-"preview, multi-backend camera support (V4L2, gPhoto2, libcamera, PipeWire, "
-"IP cameras, smartphones), real-time OpenCV effects, virtual camera output, "
-"photo and video capture, and 29 languages."
+#: usr/share/biglinux/bigcam/ui/window.py:1683
+msgid "Choose capture mode"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:22
-msgid "Image"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1033
+msgid "Click 'Start' on the AirPlay tab"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:23
-msgid "Exposure"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:925
+msgid "Click 'Start' on the Browser tab"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:24
-msgid "Focus"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1005
+msgid "Click 'Start' on the USB tab"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:25
-msgid "White Balance"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:973
+msgid "Click 'Start' on the Wi-Fi tab"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:26
-msgid "Capture"
+#: usr/share/biglinux/bigcam/core/effects.py:276
+msgid "Clip Limit"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:27
-#: usr/share/biglinux/bigcam/ui/settings_page.py:507
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:43
-msgid "Status"
+#: usr/share/biglinux/bigcam/ui/window.py:1981
+msgid "Close"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:28
-#: usr/share/biglinux/bigcam/ui/effects_page.py:25
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:292
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:481
-msgid "Advanced"
+#: usr/share/biglinux/bigcam/ui/controllers/sidebar_ctrl.py:63
+msgid "Close sidebar"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:91
-msgid "No camera selected"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:421
+msgid "Code"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:92
-msgid "Select a camera to see its controls."
+#: usr/share/biglinux/bigcam/core/effects.py:360
+msgid "Color Map"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:125
-msgid "Loading controls…"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:82
+#: usr/share/biglinux/bigcam/ui/window.py:1900
+#: usr/share/biglinux/bigcam/ui/window.py:2262
+#: usr/share/biglinux/bigcam/ui/window.py:2314
+msgid "Connect a camera or select one from the list above."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:164
-msgid "Phone camera"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:924
+msgid "Connect both devices to the same Wi-Fi network"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1003
+msgid "Connect the USB cable to the computer"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:8
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1202
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1208
+msgid "Connected"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1582
+#, python-format
+msgid "Connected to %s"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1689
+#, python-format
+msgid "Connected to %s — starting camera…"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1861
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1893
+msgid "Connected via USB"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1863
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1895
+msgid "Connected via Wi-Fi"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1191
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1316
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1338
+msgid "Connected via browser"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1557
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1666
+#, python-format
+msgid "Connecting to %s…"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:8
+msgid "Connecting…"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1587
+#: usr/share/biglinux/bigcam/ui/preview_area.py:657
+msgid "Connection failed"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:166
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1699
+msgid "Connection failed after pairing"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:15
msgid ""
-"Adjust resolution, quality, and FPS directly on the phone's browser page. "
-"Use the Effects tab for brightness, contrast, and other adjustments."
+"Connection failed. Check permissions, the address and whether another device "
+"is connected."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:173
-msgid "No adjustable controls"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:211
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:231
+msgid "Contact Card"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:174
-msgid "This camera does not expose any controls."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:478
+msgid "Container"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:326
-#: usr/share/biglinux/bigcam/ui/window.py:1164
-msgid "Profiles"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:25
+#: usr/share/biglinux/bigcam/core/effects.py:252
+msgid "Contrast"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:329
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:330
-msgid "Profile"
+#: usr/share/biglinux/bigcam/ui/window.py:266
+#: usr/share/biglinux/bigcam/ui/window.py:664
+msgid "Controls"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:340
-msgid "Manage"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:303
+msgid "Controls bar background darkness."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:341
-msgid "Manage profiles"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:320
+#: usr/share/biglinux/bigcam/ui/settings_page.py:331
+msgid "Controls opacity"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:346
-msgid "Save current settings as new profile"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:237
+msgid "Controls remain visible while using keyboard focus."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:349
-msgid "Save profile"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:285
+msgid "Controls the window background transparency."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:355
-msgid "Delete selected profile"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:409
+msgid "Coordinates"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:358
-msgid "Delete profile"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:607
+msgid "Copied!"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:365
-msgid "Reset all controls to hardware defaults"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:432
+msgid "Copy"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:369
-msgid "Hardware defaults"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:539
+msgid "Copy Address"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:427
-#: usr/share/biglinux/bigcam/ui/window.py:1162
-msgid "Save Profile"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:563
+msgid "Copy Code"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:428
-msgid "Enter a name for this profile:"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:468
+msgid "Copy Message"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:431
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:386
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:422
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:428
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:523
-#: usr/share/biglinux/bigcam/ui/window.py:2640
-msgid "Cancel"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:453
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:461
+msgid "Copy Number"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:432
-msgid "Save"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:530
+msgid "Copy PIX"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:437
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:440
-msgid "Profile name"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:487
+msgid "Copy Password"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:618
-#: usr/share/biglinux/bigcam/ui/effects_page.py:106
-#: usr/share/biglinux/bigcam/ui/settings_page.py:669
-msgid "Reset to defaults"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:571
+msgid "Copy Raw"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:621
-#, python-format
-msgid "Reset %s controls"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:495
+msgid "Copy SSID"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_selector.py:64
-msgid "Select camera"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:548
+msgid "Copy Secret"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/camera_selector.py:66
-msgid "Camera selector"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:555
+msgid "Copy URI"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/effects_page.py:22
-msgid "Adjustments"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:695
+msgid "Copy URL"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/effects_page.py:23
-msgid "Filters"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:701
+msgid "Copy URL to clipboard"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:660
+msgid "Could not connect to the camera. Check the connection and try again."
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:152
+msgid "Could not load camera controls"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:272
+msgid "Could not move files to Trash."
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:240
+msgid "Could not open the file or folder."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/effects_page.py:24
-msgid "Artistic"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:114
+msgid "Could not read the media folder."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/effects_page.py:68
-#: usr/share/biglinux/bigcam/ui/tools_page.py:76
-msgid "OpenCV not available"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:594
+msgid "Could not reset camera controls."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/effects_page.py:69
-msgid "Install python-opencv to enable video effects."
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:444
+msgid "Could not save the profile. Check its name and folder permissions."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/effects_page.py:110
+#: usr/share/biglinux/bigcam/core/phone_camera.py:166
#, python-format
-msgid "Reset %s effects"
+msgid "Could not start the camera server: %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:24
-msgid "Add IP Camera"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:396
+msgid "Countdown before taking a photo."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:42
-msgid "Camera Details"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:88
+msgid ""
+"Create a virtual camera device\n"
+"for use in video calls"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:44
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:398
-msgid "Name"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:84
+msgid "Create a virtual camera output for video calls and streaming."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:46
-msgid "Camera name"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:415
+msgid "Currency"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:50
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:696
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:287
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:393
-msgid "URL"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:146
+msgid "Dark"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:53
-msgid "Camera URL (RTSP or HTTP)"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:360
+msgid "Delete profile"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:60
-msgid "Add Camera"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:357
+msgid "Delete selected profile"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:64
-msgid "Add IP camera"
+#: usr/share/biglinux/bigcam/core/effects.py:307
+msgid "Denoise"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/notification.py:59
-msgid "Dismiss"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:545
+#: usr/share/biglinux/bigcam/ui/tools_page.py:98
+msgid "Detect QR codes in the camera feed"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/notification.py:62
-msgid "Dismiss notification"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:392
+msgid "Detect paired devices on your network"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:57
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:87
-#: usr/share/biglinux/bigcam/ui/window.py:155
-msgid "Phone as Webcam"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:450
+msgid ""
+"Detects the address automatically. Requires 'Pair with code' (not QR) open "
+"on the phone."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:109
-msgid "About connection methods"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:236
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:360
+#: usr/share/biglinux/bigcam/ui/settings_page.py:561
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:59
+msgid "Device"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:112
-msgid "Information about connection methods"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:607
+msgid "Device name"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:126
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:931
-msgid "Browser"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:337
+msgid "Direct V4L2 access"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:134
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:976
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:155
-msgid "Wi-Fi"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:7
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1871
+msgid "Disconnected"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:142
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1008
-msgid "USB"
+#: usr/share/biglinux/bigcam/ui/notification.py:59
+msgid "Dismiss"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:150
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1038
-msgid "AirPlay"
+#: usr/share/biglinux/bigcam/ui/notification.py:62
+msgid "Dismiss notification"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:195
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1311
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1357
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1372
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1518
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1891
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1946
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:2020
-msgid "Idle"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:108
+msgid "Don't show this warning again"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:248
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:371
-#: usr/share/biglinux/bigcam/ui/settings_page.py:513
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:59
-msgid "Device"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:106
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:135
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:400
+msgid "E-mail"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:249
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:372
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1629
-msgid "Searching…"
+#: usr/share/biglinux/bigcam/core/effects.py:347
+msgid "Edge Detection"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:257
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:391
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:84
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:103
-#: usr/share/biglinux/bigcam/ui/window.py:1169
-msgid "Refresh"
+#: usr/share/biglinux/bigcam/ui/window.py:665
+msgid "Effects"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:269
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:488
-msgid "Lens"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:964
+msgid "Enable Developer Options (same steps as USB above)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:271
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:490
-msgid "Rear camera"
+#: usr/share/biglinux/bigcam/ui/window.py:472
+msgid "Enable Virtual Camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:271
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:490
-msgid "Front camera"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:83
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:90
+msgid "Enable virtual camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:278
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:494
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:595
-msgid "Quality"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:568
+msgid "Enable virtual camera service"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:280
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:496
-msgid "Maximum"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:412
+msgid "End"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:299
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:501
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:605
-msgid "FPS"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1626
+msgid "Enter IP:Port and pairing code"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:303
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:505
-msgid "Bitrate"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:418
+msgid "Enter a name for this profile:"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:325
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:528
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:631
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:742
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:411
-msgid "Start"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1901
+msgid "Error"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:326
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:529
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:632
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:743
-msgid "Stop"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:709
+#, python-format
+msgid "Error: %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:381
-msgid "Switch device to Wi-Fi mode"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:25
+msgid "Exposure"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:402
-msgid "Find wireless devices"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:36
+msgid "Exposure Auto Priority"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:403
-msgid "Detect paired devices on your network"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:43
+msgid "Exposure Bias"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:411
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1581
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1615
-msgid "Scan"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:34
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:35
+msgid "Exposure Time"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:426
-msgid "Pair new device"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:287
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:490
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:595
+msgid "FPS"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:427
-msgid "Only needed once per device"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:377
+msgid "FPS limit"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:436
-msgid "6-digit code (shown on phone screen)"
+#: usr/share/biglinux/bigcam/ui/window.py:1773
+msgid "Failed to capture photo."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:442
-msgid "IP:Port (shown on phone screen)"
+#: usr/share/biglinux/bigcam/ui/window.py:1803
+msgid "Failed to capture photo. Check the camera mode and connection."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:448
-msgid "Pair"
+#: usr/share/biglinux/bigcam/core/stream_engine.py:620
+msgid "Failed to obtain GStreamer source for this camera."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:459
-msgid "Auto-fill IP:Port"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1960
+msgid "Failed to start AirPlay. Try again."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:461
-msgid ""
-"Detects the address automatically. Requires 'Pair with code' (not QR) open "
-"on the phone."
+#: usr/share/biglinux/bigcam/core/stream_engine.py:709
+#: usr/share/biglinux/bigcam/core/stream_engine.py:966
+#: usr/share/biglinux/bigcam/core/stream_engine.py:976
+#: usr/share/biglinux/bigcam/core/stream_engine.py:1202
+msgid "Failed to start camera stream."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:466
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1637
-msgid "Find"
+#: usr/share/biglinux/bigcam/core/stream_engine.py:605
+msgid "Failed to start camera streaming process."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:578
-msgid ""
-"AirPlay mirrors the iPhone screen. Open the Camera app on your iPhone after "
-"connecting to use it as a webcam."
+#: usr/share/biglinux/bigcam/ui/window.py:1391
+#: usr/share/biglinux/bigcam/ui/window.py:1403
+msgid "Failed to start camera streaming."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:584
-msgid "Visible name"
+#: usr/share/biglinux/bigcam/ui/window.py:2121
+msgid "Failed to start recording."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:612
-msgid "Rotation"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1260
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1263
+msgid "Failed to start server"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:615
-msgid "None"
+#: usr/share/biglinux/bigcam/ui/effects_page.py:23
+msgid "Filters"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:616
-msgid "90° Right"
+#: usr/share/biglinux/bigcam/ui/window.py:2100
+#: usr/share/biglinux/bigcam/ui/window.py:2145
+msgid "Finalizing video…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:617
-msgid "90° Left"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:455
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1601
+msgid "Find"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:618
-msgid "180°"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:391
+msgid "Find wireless devices"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:672
-msgid "Open this URL in any phone browser to stream the camera"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:96
+msgid ""
+"Fine-tune exposure, white balance\n"
+"and save per-camera profiles"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:685
-msgid "QR Code — scan with your phone"
+#: usr/share/biglinux/bigcam/ui/window.py:1216
+msgid "Finish the current capture before changing cameras."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:697
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1313
-msgid "Start to see the address"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:962
+msgid "First-time pairing (without USB)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:704
-msgid "Copy URL"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:77
+msgid ""
+"Flip the camera preview\n"
+"horizontally like a mirror"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:710
-msgid "Copy URL to clipboard"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:252
+msgid "Flip the preview horizontally like a mirror."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:721
-msgid "Port"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:26
+msgid "Focus"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:38
+msgid "Focus Distance"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:814
+#: usr/share/biglinux/bigcam/ui/window.py:1975
#, python-format
-msgid "Install required: %s"
+msgid "Force close %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:835
-msgid "How to connect your phone"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1606
+#, python-format
+msgid "Found %s — enter the 6-digit code below"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:916
-msgid "Browser (Android / iPhone)"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1555
+#, python-format
+msgid "Found: %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:917
-msgid "Works with any phone, no app required."
+#: usr/share/biglinux/bigcam/core/phone_strings.py:11
+msgid "Frames per second"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:921
-msgid "Connect both devices to the same Wi-Fi network"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:12
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:259
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:479
+msgid "Front camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:922
-msgid "Click 'Start' on the Browser tab"
+#: usr/share/biglinux/bigcam/ui/window.py:594
+msgid "Fullscreen (F11)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:923
-msgid "Scan the QR code with your phone or type the URL"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:32
+msgid "Gain"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:924
-msgid "Accept the security warning in the browser"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:49
+msgid "Gallery status"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:925
-msgid "Tap 'Start' on the phone's browser page"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:29
+#: usr/share/biglinux/bigcam/core/effects.py:264
+msgid "Gamma"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:947
-msgid "Wi-Fi (Android 11+)"
+#: usr/share/biglinux/bigcam/core/effects.py:260
+msgid "Gamma Correction"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:948
-msgid "No cable needed after the first setup."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:103
+msgid "General"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:951
-msgid "Quick method"
+#: usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py:211
+msgid "Generic Camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:953
-msgid ""
-"If connected via USB, use the wireless icon on the device selector to switch "
-"to Wi-Fi instantly."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:965
+msgid "Go to Settings → Developer Options → Wireless Debugging"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:959
-msgid "First-time pairing (without USB)"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1002
+msgid "Go to Settings → Developer Options → enable 'USB Debugging'"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:961
-msgid "Enable Developer Options (same steps as USB above)"
+#: usr/share/biglinux/bigcam/core/effects.py:320
+msgid "Grayscale"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:962
-msgid "Go to Settings → Developer Options → Wireless Debugging"
+#: usr/share/biglinux/bigcam/core/effects.py:277
+msgid "Grid Size"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:963
-msgid "Tap 'Pair device with pairing code'"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:274
+msgid "Grid overlay"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:964
-msgid "Use 'pairing CODE', NOT 'QR Code'"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:37
+msgid "Grid view"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:965
-msgid "Note the IP:Port and 6-digit code shown on the phone"
+#: usr/share/biglinux/bigcam/ui/window.py:2624
+msgid "HTTPS/WebSocket server for phone camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:966
-msgid "On the Wi-Fi tab, expand 'Pair new device'"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:371
+msgid "Hardware defaults"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:967
-msgid "Type the IP:Port and code, then tap 'Pair'"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:406
+msgid "Hidden"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:968
-msgid "Or tap 'Find' to auto-fill the IP:Port"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:12
+msgid "High"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:969
-msgid "After pairing, tap 'Scan' to find the device"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:81
+msgid "High resource usage detected"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:970
-msgid "Click 'Start' on the Wi-Fi tab"
+#: usr/share/biglinux/bigcam/ui/window.py:2523
+msgid "High resource usage detected. Optimized automatically."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:992
-msgid "USB (Android)"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:598
+msgid "How many virtual camera devices to create (one per camera)."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:993
-msgid "The easiest and fastest method."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:838
+msgid "How to connect your phone"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:997
-msgid "On your Android phone, go to Settings → About Phone"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:27
+msgid "Hue"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:998
-msgid "Tap 'Build Number' 7 times to unlock Developer Options"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:117
+msgid "I understand, continue"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:999
-msgid "Go to Settings → Developer Options → enable 'USB Debugging'"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:431
+msgid "IP:Port (shown on phone screen)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1000
-msgid "Connect the USB cable to the computer"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:46
+msgid "ISO Sensitivity"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1001
-msgid "Accept the USB Debugging prompt on the phone screen"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:183
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1280
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1326
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1341
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1898
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1973
+msgid "Idle"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1002
-msgid "Click 'Start' on the USB tab"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:956
+msgid ""
+"If connected via USB, use the wireless icon on the device selector to switch "
+"to Wi-Fi instantly."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1024
-msgid "AirPlay (iPhone / iPad)"
+#: usr/share/biglinux/bigcam/ui/window.py:2393
+msgid ""
+"If you choose to keep it running, the camera will remain on after closing "
+"the application."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1025
-msgid "Mirrors the entire screen (not just the camera)."
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:24
+msgid "Image"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1029
-msgid "Make sure both devices are on the same Wi-Fi network"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:45
+msgid "Image Stabilization"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1030
-msgid "Click 'Start' on the AirPlay tab"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:13
+msgid "Include microphone audio"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1031
-msgid "On your iPhone, open Control Center (swipe down from top-right)"
+#: usr/share/biglinux/bigcam/ui/effects_page.py:70
+msgid "Install python-opencv to enable video effects."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1032
-msgid "Tap 'Screen Mirroring' and select 'BigCam'"
+#: usr/share/biglinux/bigcam/ui/tools_page.py:77
+msgid "Install python-opencv to use tools."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1209
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1347
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1369
-msgid "Connected via browser"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:817
+#, python-format
+msgid "Install required: %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1214
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1276
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1354
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1366
-msgid "Waiting for connection…"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:786
+msgid "Instructions"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1241
-msgid "Connected"
+#: usr/share/biglinux/bigcam/core/phone_camera.py:129
+msgid "Invalid server port"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1246
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:2030
-msgid "AirPlay connected"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:418
+msgid "Issuer"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1258
-msgid "Starting server…"
+#: usr/share/biglinux/bigcam/ui/window.py:2401
+msgid "Keep camera on"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1273
-#, python-format
-msgid "Server listening on port %d"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:51
+msgid "LED Frequency"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1291
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1294
-msgid "Failed to start server"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:50
+msgid "LED Mode"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1442
-msgid "No USB device connected"
+#: usr/share/biglinux/bigcam/ui/window.py:418
+#: usr/share/biglinux/bigcam/ui/window.py:745
+msgid "Last photo"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1446
-msgid ""
-"No device found. Connect via USB cable and enable USB Debugging, then tap "
-"'Refresh'."
+#: usr/share/biglinux/bigcam/ui/window.py:745
+msgid "Last video"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1455
-msgid "Selected device is no longer available. Tap 'Refresh' to update."
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:407
+msgid "Latitude"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1485
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1859
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1962
-msgid "No v4l2loopback device"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:257
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:477
+msgid "Lens"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1492
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1866
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1937
-msgid "Starting…"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:154
+msgid "Let's Start"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1569
-msgid "Scanning…"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:146
+msgid "Light"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1571
-msgid "Scanning network…"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:38
+msgid "List view"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1583
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1804
-msgid "No devices found"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:523
+msgid "Live"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1585
-msgid "No devices found. Try 'Pair new device'."
+#: usr/share/biglinux/bigcam/ui/window.py:1045
+msgid "Load Profile"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1591
-#, python-format
-msgid "Found: %s"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:62
+msgid "Load more"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1593
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1702
-#, python-format
-msgid "Connecting to %s…"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:149
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:155
+msgid "Loaded"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1618
-#, python-format
-msgid "Connected to %s"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:139
+msgid "Loading controls…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1619
-msgid "Ready to start camera"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:105
+msgid "Loading media…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1623
-#: usr/share/biglinux/bigcam/ui/preview_area.py:654
-msgid "Connection failed"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:165
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:413
+msgid "Location"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1642
-#, python-format
-msgid "Found %s — enter the 6-digit code below"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:408
+msgid "Longitude"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1648
-msgid ""
-"Not found. Make sure you tapped 'Pair with pairing code' (not QR Code) and "
-"that the code screen is still open. You can also type the IP:Port manually."
+#: usr/share/biglinux/bigcam/core/phone_strings.py:12
+msgid "Low"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1662
-msgid "Enter IP:Port and pairing code"
+#: usr/share/biglinux/bigcam/ui/window.py:336
+msgid "Main menu"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1667
-msgid "Pairing…"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1032
+msgid "Make sure both devices are on the same Wi-Fi network"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1689
-msgid "Scanning for paired device…"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:342
+msgid "Manage"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1698
-msgid "No devices found after pairing"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:343
+msgid "Manage profiles"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1725
-#, python-format
-msgid "Connected to %s — starting camera…"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:569
+msgid "Master switch to allow virtual camera outputs."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1735
-msgid "Connection failed after pairing"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:268
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:485
+msgid "Maximum"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1752
-msgid "No device available to start"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:597
+msgid "Maximum virtual cameras"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1770
-msgid "Switching to Wi-Fi…"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:12
+msgid "Medium"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1781
-#, python-format
-msgid "Wi-Fi: %s (unplug USB)"
+#: usr/share/biglinux/bigcam/ui/window.py:334
+msgid "Menu"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1786
-msgid "Wi-Fi switch failed"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:396
+msgid "Message"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1820
-msgid "No device found. Pair a device first, then tap 'Scan'."
+#: usr/share/biglinux/bigcam/core/phone_strings.py:16
+msgid ""
+"Microphone audio is unavailable. Stop and reconnect, or turn off microphone "
+"audio."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1829
-msgid "Selected device is no longer available. Tap 'Scan' to refresh."
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:76
+msgid "Mirror Preview"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1902
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1941
-msgid "Connected via USB"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:251
+#: usr/share/biglinux/bigcam/ui/settings_page.py:257
+#: usr/share/biglinux/bigcam/ui/window.py:431
+msgid "Mirror preview"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1904
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1943
-msgid "Connected via Wi-Fi"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1028
+msgid "Mirrors the entire screen (not just the camera)."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1911
-msgid "Disconnected"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:68
+msgid "Module"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1949
-msgid "Error"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:985
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:152
+msgid "Module loaded"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1991
-msgid "Starting AirPlay…"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:131
+msgid "Module not available for current kernel"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:2007
-msgid "Failed to start AirPlay. Try again."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:990
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:158
+msgid "Module not loaded"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:2044
-msgid "AirPlay stopped unexpectedly"
-msgstr ""
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:249
+#, python-format
+msgid "Move %d item to Trash?"
+msgid_plural "Move %d items to Trash?"
+msgstr[0] ""
+msgstr[1] ""
-#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:2052
-msgid "Waiting for AirPlay connection…"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:168
+#, python-format
+msgid "Move %s to Trash"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:56
-msgid "Captured Photos"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:78
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:167
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:252
+msgid "Move to Trash"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:63
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:82
-msgid "Grid view"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:71
+msgid "Multiple Cameras"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:66
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:85
-msgid "List view"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:295
+#: usr/share/biglinux/bigcam/ui/preview_area.py:297
+#: usr/share/biglinux/bigcam/ui/preview_area.py:461
+#: usr/share/biglinux/bigcam/ui/preview_area.py:463
+msgid "Mute"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:78
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:97
-msgid "Select items"
+#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:44
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:398
+msgid "Name"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:90
-#: usr/share/biglinux/bigcam/ui/settings_page.py:111
-msgid "Open photos folder"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:612
+msgid ""
+"Name template for virtual cameras. Devices will be named ' 1', ' "
+"2', etc.\n"
+"Press Enter or click ✓ to apply."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:136
-msgid "No photos yet"
+#: usr/share/biglinux/bigcam/core/effects.py:338
+msgid "Negative"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:137
-msgid "Captured photos will appear here."
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:403
+msgid "Network (SSID)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:146
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:180
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:165
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:199
-msgid "0 selected"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1411
+msgid "No USB device connected"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:149
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:168
-msgid "Select All"
+#: usr/share/biglinux/bigcam/ui/window.py:2106
+msgid "No active camera stream."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:153
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:292
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:354
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:387
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:423
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:172
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:346
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:395
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:429
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:524
-msgid "Delete"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:187
+msgid "No adjustable controls"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:180
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:199
-#, python-format
-msgid "%d selected"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:951
+msgid "No cable needed after the first setup."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:383
-#, python-format
-msgid "Delete %d photos?"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:81
+#: usr/share/biglinux/bigcam/ui/window.py:1899
+#: usr/share/biglinux/bigcam/ui/window.py:2261
+#: usr/share/biglinux/bigcam/ui/window.py:2313
+msgid "No camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:384
-msgid "These photos will be permanently deleted."
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:97
+msgid "No camera selected"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:419
-msgid "Delete photo?"
+#: usr/share/biglinux/bigcam/ui/window.py:1675
+msgid "No camera selected."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/photo_gallery.py:420
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:521
-#, python-format
-msgid "\"%s\" will be permanently deleted."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:1009
+msgid "No cameras connected"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:81
-#: usr/share/biglinux/bigcam/ui/window.py:2015
-#: usr/share/biglinux/bigcam/ui/window.py:2575
-msgid "No camera"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1716
+msgid "No device available to start"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:82
-#: usr/share/biglinux/bigcam/ui/window.py:2016
-#: usr/share/biglinux/bigcam/ui/window.py:2576
-msgid "Connect a camera or select one from the list above."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1415
+msgid ""
+"No device found. Connect via USB cable and enable USB Debugging, then tap "
+"'Refresh'."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:89
-#: usr/share/biglinux/bigcam/ui/preview_area.py:95
-#: usr/share/biglinux/bigcam/ui/window.py:1743
-#: usr/share/biglinux/bigcam/ui/window.py:2267
-msgid "Try again"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1784
+msgid "No device found. Pair a device first, then tap 'Scan'."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:223
-#: usr/share/biglinux/bigcam/ui/preview_area.py:225
-#: usr/share/biglinux/bigcam/ui/window.py:467
-#: usr/share/biglinux/bigcam/ui/window.py:469
-#: usr/share/biglinux/bigcam/ui/window.py:787
-msgid "Capture photo"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1547
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1768
+msgid "No devices found"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:247
-#: usr/share/biglinux/bigcam/ui/preview_area.py:682
-#: usr/share/biglinux/bigcam/ui/window.py:494
-msgid "Record video (Ctrl+R)"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1662
+msgid "No devices found after pairing"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:249
-#: usr/share/biglinux/bigcam/ui/window.py:496
-msgid "Record video"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1549
+msgid "No devices found. Try 'Pair new device'."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:295
-#: usr/share/biglinux/bigcam/ui/preview_area.py:297
-#: usr/share/biglinux/bigcam/ui/preview_area.py:459
-msgid "Mute"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:100
+msgid ""
+"No features can be disabled (active camera sources cannot be stopped from "
+"here)."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:456
-msgid "Unmute"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:232
+msgid "No media yet"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:579
-#: usr/share/biglinux/bigcam/ui/window.py:1403
-#: usr/share/biglinux/bigcam/ui/window.py:1474
-#: usr/share/biglinux/bigcam/ui/window.py:1792
-#: usr/share/biglinux/bigcam/ui/window.py:1858
-msgid "Please wait…"
+#: usr/share/biglinux/bigcam/ui/window.py:2215
+msgid "No profiles found."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:647
-msgid "Camera in use by:"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1454
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1823
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1915
+msgid "No v4l2loopback device"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:648
-#: usr/share/biglinux/bigcam/ui/preview_area.py:651
-msgid "Camera busy"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:605
+msgid "None"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:650
-msgid "Camera is being used"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1612
+msgid ""
+"Not found. Make sure you tapped 'Pair with pairing code' (not QR Code) and "
+"that the code screen is still open. You can also type the IP:Port manually."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:657
-msgid "Could not connect to the camera. Check the connection and try again."
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:137
+msgid "Not installed"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:676
-#: usr/share/biglinux/bigcam/ui/window.py:2439
-msgid "Stop recording"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:562
+#: usr/share/biglinux/bigcam/ui/settings_page.py:992
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:60
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:160
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:161
+msgid "Not loaded"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/preview_area.py:725
-#: usr/share/biglinux/bigcam/ui/preview_area.py:736
-#, python-brace-format
-msgid "{n} seconds remaining"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:968
+msgid "Note the IP:Port and 6-digit code shown on the phone"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:84
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:399
-#: usr/share/biglinux/bigcam/ui/window.py:146
-msgid "Phone"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:395
+msgid "Number"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:93
-msgid "SMS"
+#: usr/share/biglinux/bigcam/ui/window.py:1837
+msgid "OK"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:106
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:135
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:400
-msgid "E-mail"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:400
+#: usr/share/biglinux/bigcam/ui/window.py:572
+#: usr/share/biglinux/bigcam/ui/window.py:877
+#: usr/share/biglinux/bigcam/ui/window.py:971
+msgid "Off"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:165
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:413
-msgid "Location"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:969
+msgid "On the Wi-Fi tab, expand 'Pair new device'"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:179
-msgid "Calendar Event"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1000
+msgid "On your Android phone, go to Settings → About Phone"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:211
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:231
-msgid "Contact Card"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1034
+msgid "On your iPhone, open Control Center (swipe down from top-right)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:236
-msgid "Payment (PIX)"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:14
+msgid ""
+"Only connect on a trusted network. Anyone with this address can connect to "
+"the camera service."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:263
-msgid "Authentication (TOTP)"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:416
+msgid "Only needed once per device"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:276
-msgid "App Store"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:156
+#, python-format
+msgid "Open %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:283
-msgid "Social Network"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:477
+msgid "Open E-mail Client"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:292
-msgid "Barcode"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:69
+msgid "Open folder"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:295
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:394
-msgid "Text"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:444
+msgid "Open in Browser"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:327
-msgid "QR Code Detected"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:503
+msgid "Open in Maps"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:362
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:80
-msgid "Actions"
+#: usr/share/biglinux/bigcam/ui/window.py:420
+msgid "Open last photo"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:395
-msgid "Number"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:70
+msgid "Open media folder"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:396
-msgid "Message"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:116
+msgid "Open photos folder"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:397
-msgid "Address"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:663
+msgid "Open this URL in any phone browser to stream the camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:401
-msgid "Organization"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:133
+msgid "Open videos folder"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:402
-msgid "Title"
+#: usr/share/biglinux/bigcam/ui/effects_page.py:69
+#: usr/share/biglinux/bigcam/ui/tools_page.py:76
+msgid "OpenCV not available"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:403
-msgid "Network (SSID)"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:119
+msgid "Optimize (disable heavy features)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:404
-msgid "Password"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:971
+msgid "Or tap 'Find' to auto-fill the IP:Port"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:405
-msgid "Security"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:401
+msgid "Organization"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:406
-msgid "Hidden"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:302
+#: usr/share/biglinux/bigcam/ui/settings_page.py:313
+msgid "Overlay opacity"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:407
-msgid "Latitude"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:437
+msgid "Pair"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:408
-msgid "Longitude"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:415
+msgid "Pair new device"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:409
-msgid "Coordinates"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1631
+msgid "Pairing…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:410
-msgid "Summary"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:40
+msgid "Pan"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:412
-msgid "End"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:404
+msgid "Password"
msgstr ""
#: usr/share/biglinux/bigcam/ui/qr_dialog.py:414
msgid "Payload"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:415
-msgid "Currency"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:236
+msgid "Payment (PIX)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:416
-msgid "Account"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:84
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:399
+#: usr/share/biglinux/bigcam/ui/window.py:159
+msgid "Phone"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:417
-msgid "Secret"
+#: usr/share/biglinux/bigcam/ui/window.py:2378
+msgid "Phone (AirPlay)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:418
-msgid "Issuer"
+#: usr/share/biglinux/bigcam/ui/window.py:2370
+msgid "Phone (Browser Wi-Fi)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:419
-msgid "URI"
+#: usr/share/biglinux/bigcam/ui/window.py:2374
+msgid "Phone (USB/Wi-Fi scrcpy)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:420
-msgid "Subject"
+#: usr/share/biglinux/bigcam/ui/controllers/mobile_device_ctrl.py:82
+msgid "Phone Mic (Browser)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:421
-msgid "Code"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:7
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:58
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:92
+#: usr/share/biglinux/bigcam/ui/window.py:168
+msgid "Phone as Webcam"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:432
-msgid "Copy"
+#: usr/share/biglinux/bigcam/ui/window.py:1050
+msgid "Phone as Webcam…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:444
-msgid "Open in Browser"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:178
+msgid "Phone camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:453
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:461
-msgid "Copy Number"
+#: usr/share/biglinux/bigcam/ui/window.py:2623
+msgid "Phone camera server"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:468
-msgid "Copy Message"
+#: usr/share/biglinux/bigcam/core/stream_engine.py:1485
+msgid "Phone camera server not available."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:472
-msgid "WhatsApp"
+#: usr/share/biglinux/bigcam/ui/window.py:185
+msgid "Phone camera status"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:477
-msgid "Open E-mail Client"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:68
+msgid "Photo & Video Capture"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:487
-msgid "Copy Password"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:110
+msgid "Photo directory"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:495
-msgid "Copy SSID"
+#: usr/share/biglinux/bigcam/ui/window.py:380
+#: usr/share/biglinux/bigcam/ui/window.py:382
+msgid "Photo mode"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:503
-msgid "Open in Maps"
+#: usr/share/biglinux/bigcam/ui/tools_page.py:396
+#: usr/share/biglinux/bigcam/ui/window.py:1767
+#: usr/share/biglinux/bigcam/ui/window.py:1801
+msgid "Photo saved!"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:512
-msgid "Save .ics File"
+#: usr/share/biglinux/bigcam/ui/window.py:666
+msgid "Photos"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:521
-msgid "Save Contact"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:582
+#: usr/share/biglinux/bigcam/ui/window.py:1303
+#: usr/share/biglinux/bigcam/ui/window.py:1410
+#: usr/share/biglinux/bigcam/ui/window.py:1784
+msgid "Please wait…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:530
-msgid "Copy PIX"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:712
+msgid "Port"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:539
-msgid "Copy Address"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:42
+msgid "Power Line Frequency"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:548
-msgid "Copy Secret"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:245
+msgid "Preview"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:555
-msgid "Copy URI"
+#: usr/share/biglinux/bigcam/ui/window.py:1689
+msgid "Preview screenshot"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:563
-msgid "Copy Code"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:331
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:332
+msgid "Profile"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:571
-msgid "Copy Raw"
+#: usr/share/biglinux/bigcam/ui/window.py:2227
+#, python-format
+msgid "Profile loaded: %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:574
-msgid "Save to File"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:427
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:430
+msgid "Profile name"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:607
-msgid "Copied!"
+#: usr/share/biglinux/bigcam/ui/window.py:2205
+msgid "Profile saved."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:643
-msgid "iCalendar Files"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:328
+#: usr/share/biglinux/bigcam/ui/window.py:1046
+msgid "Profiles"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:669
-msgid "vCard Files"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:327
+msgid "QR Code Detected"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:674
-msgid "Text Files"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:542
+#: usr/share/biglinux/bigcam/ui/tools_page.py:93
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:79
+msgid "QR Code Scanner"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:689
-msgid "All Files"
+#: usr/share/biglinux/bigcam/ui/window.py:1667
+msgid "QR Code detected!"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:705
-#, python-format
-msgid "Saved: %s"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:676
+msgid "QR Code — scan with your phone"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/qr_dialog.py:709
-#, python-format
-msgid "Error: %s"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:10
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:266
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:483
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:585
+msgid "Quality"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:76
-msgid "High resource usage detected"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:954
+msgid "Quick method"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:80
-#, python-format
-msgid "The application is using %s and %s."
+#: usr/share/biglinux/bigcam/ui/window.py:1057
+msgid "Quit"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:82
-msgid "Active features that may be causing this:"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:220
+msgid "Re-enable all dismissed resource usage warnings."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:85
-msgid "active source"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1583
+msgid "Ready to start camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:90
-msgid ""
-"You can optimize by disabling the heaviest features, or continue if you "
-"understand the impact."
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:84
+msgid "Real-Time Effects"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:95
-msgid ""
-"No features can be disabled (active camera sources cannot be stopped from "
-"here)."
+#: usr/share/biglinux/bigcam/ui/window.py:2583
+msgid "Real-time filters, background blur, artistic effects"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:103
-msgid "Don't show this warning again"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:11
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:259
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:479
+msgid "Rear camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:107
-msgid "Suppress resource usage warnings permanently"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:973
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:133
+msgid "Reboot required (kernel updated)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:112
-msgid "I understand, continue"
+#: usr/share/biglinux/bigcam/ui/window.py:1040
+msgid "Record Video"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:114
-msgid "Optimize (disable heavy features)"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:249
+#: usr/share/biglinux/bigcam/ui/preview_area.py:677
+#: usr/share/biglinux/bigcam/ui/window.py:520
+msgid "Record video"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:98
-msgid "General"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:247
+#: usr/share/biglinux/bigcam/ui/preview_area.py:688
+#: usr/share/biglinux/bigcam/ui/window.py:518
+msgid "Record video (Ctrl+R)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:100
-msgid "Reset general settings"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:33
+msgid "Recorded Videos"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:105
-msgid "Photo directory"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:416
+msgid "Recording"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:122
-msgid "Video directory"
+#: usr/share/biglinux/bigcam/ui/window.py:2168
+#, python-format
+msgid "Recording failed. Any partial file has been preserved: %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:128
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:109
-msgid "Open videos folder"
+#: usr/share/biglinux/bigcam/ui/window.py:2142
+msgid "Recording…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:138
-msgid "Theme"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:236
+msgid "Reduce motion and flashes"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:141
-msgid "Light"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:65
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:245
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:380
+#: usr/share/biglinux/bigcam/ui/window.py:1051
+msgid "Refresh"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:141
-msgid "Dark"
+#: usr/share/biglinux/bigcam/ui/window.py:324
+msgid "Refresh camera list"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:149
-msgid "Application theme"
+#: usr/share/biglinux/bigcam/ui/window.py:322
+msgid "Refresh cameras"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:155
-#: usr/share/biglinux/bigcam/ui/settings_page.py:161
-msgid "USB hotplug detection"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:66
+msgid "Refresh gallery"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:156
-msgid "Automatically detect cameras when plugged or unplugged."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:225
+msgid "Reset"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:168
-#: usr/share/biglinux/bigcam/ui/settings_page.py:176
-msgid "Show help on hover"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:573
+#, python-format
+msgid "Reset %s controls"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:169
-msgid "Show tooltip hints when hovering over buttons."
+#: usr/share/biglinux/bigcam/ui/effects_page.py:111
+#, python-format
+msgid "Reset %s effects"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:183
-#: usr/share/biglinux/bigcam/ui/settings_page.py:191
-msgid "Resource usage monitor"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:367
+msgid "Reset all controls to hardware defaults"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:184
-msgid "Warn when CPU or memory usage is high."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:350
+msgid "Reset camera settings"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:199
-msgid "Reset resource warnings"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:228
+msgid "Reset dismissed resource warnings"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:200
-msgid "Re-enable all dismissed resource usage warnings."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:105
+msgid "Reset general settings"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:205
-msgid "Reset"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:247
+msgid "Reset preview settings"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:208
-msgid "Reset dismissed resource warnings"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:418
+msgid "Reset recording settings"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:218
-msgid "Preview"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:219
+msgid "Reset resource warnings"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:220
-msgid "Reset preview settings"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:570
+#: usr/share/biglinux/bigcam/ui/effects_page.py:107
+#: usr/share/biglinux/bigcam/ui/settings_page.py:771
+msgid "Reset to defaults"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:224
-#: usr/share/biglinux/bigcam/ui/settings_page.py:230
-#: usr/share/biglinux/bigcam/ui/window.py:419
-msgid "Mirror preview"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:10
+#: usr/share/biglinux/bigcam/ui/settings_page.py:354
+msgid "Resolution"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:225
-msgid "Flip the preview horizontally like a mirror."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:188
+#: usr/share/biglinux/bigcam/ui/settings_page.py:196
+msgid "Resource usage monitor"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:236
-#: usr/share/biglinux/bigcam/ui/settings_page.py:241
-msgid "Show FPS counter"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:602
+msgid "Rotation"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:247
-msgid "Grid overlay"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:93
+msgid "SMS"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:248
-msgid "Show a rule-of-thirds grid over the preview."
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:26
+msgid "Saturation"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:257
-msgid "Background transparency"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:422
+msgid "Save"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:258
-msgid "Controls the window background transparency."
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:512
+msgid "Save .ics File"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:274
-msgid "Overlay opacity"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:521
+msgid "Save Contact"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:275
-msgid "Controls bar background darkness."
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:417
+#: usr/share/biglinux/bigcam/ui/window.py:1044
+msgid "Save Profile"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:291
-msgid "Controls opacity"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:348
+msgid "Save current settings as new profile"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:292
-msgid "Transparency of the buttons over the preview."
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:351
+msgid "Save profile"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:307
-msgid "Direct V4L2 access"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:574
+msgid "Save to File"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:308
-msgid ""
-"Bypass PipeWire and access the camera directly. May fix flickering on some "
-"webcams."
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:705
+#, python-format
+msgid "Saved: %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:318
-msgid "Camera"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:400
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1545
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1579
+msgid "Scan"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:320
-msgid "Reset camera settings"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:544
+#: usr/share/biglinux/bigcam/ui/tools_page.py:97
+#: usr/share/biglinux/bigcam/ui/window.py:446
+msgid "Scan QR Codes"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:324
-msgid "Resolution"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:80
+msgid ""
+"Scan QR codes and barcodes\n"
+"directly from the camera feed"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:327
-#: usr/share/biglinux/bigcam/ui/settings_page.py:350
-msgid "Auto"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:926
+msgid "Scan the QR code with your phone or type the URL"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:347
-msgid "FPS limit"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1653
+msgid "Scanning for paired device…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:365
-#: usr/share/biglinux/bigcam/ui/window.py:555
-msgid "Capture timer"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1535
+msgid "Scanning network…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:366
-msgid "Countdown before taking a photo."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1533
+msgid "Scanning…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:370
-#: usr/share/biglinux/bigcam/ui/window.py:548
-#: usr/share/biglinux/bigcam/ui/window.py:980
-#: usr/share/biglinux/bigcam/ui/window.py:1094
-msgid "Off"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:48
+msgid "Scene Mode"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:386
-msgid "Recording"
+#: usr/share/biglinux/bigcam/ui/window.py:2633
+msgid "Scrcpy (Android camera)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:388
-msgid "Reset recording settings"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:237
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:361
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1593
+msgid "Searching…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:394
-msgid "Video Codec"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:417
+msgid "Secret"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:413
-msgid "Audio Codec"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:405
+msgid "Security"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:432
-msgid "Container"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:162
+msgid "Select"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:470
-msgid "Video Bitrate (kbps)"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:163
+#, python-format
+msgid "Select %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:494
-#: usr/share/biglinux/bigcam/ui/tools_page.py:93
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:100
-msgid "QR Code Scanner"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:98
+msgid "Select a camera to see its controls."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:496
-#: usr/share/biglinux/bigcam/ui/tools_page.py:97
-#: usr/share/biglinux/bigcam/ui/window.py:434
-msgid "Scan QR Codes"
+#: usr/share/biglinux/bigcam/ui/camera_selector.py:64
+msgid "Select camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:497
-#: usr/share/biglinux/bigcam/ui/tools_page.py:98
-msgid "Detect QR codes in the camera feed"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:75
+msgid "Select displayed items"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:505
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:40
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:105
-msgid "Virtual Camera"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:43
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:44
+msgid "Select items"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:514
-#: usr/share/biglinux/bigcam/ui/settings_page.py:879
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:60
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:154
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:155
-msgid "Not loaded"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:579
+msgid "Select which cameras should output to a virtual device."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:520
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:83
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:90
-msgid "Enable virtual camera"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1424
+msgid "Selected device is no longer available. Tap 'Refresh' to update."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:521
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:84
-msgid "Create a virtual camera output for video calls and streaming."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1793
+msgid "Selected device is no longer available. Tap 'Scan' to refresh."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:862
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:129
-msgid "v4l2loopback not available"
+#: usr/share/biglinux/bigcam/ui/tools_page.py:115
+msgid "Sensitivity"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:867
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:140
-msgid "Active"
+#: usr/share/biglinux/bigcam/core/effects.py:329
+msgid "Sepia"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:872
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:146
-msgid "Module loaded"
+#: usr/share/biglinux/bigcam/core/phone_camera.py:144
+msgid "Server did not start in time"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/settings_page.py:877
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:152
-msgid "Module not loaded"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1242
+#, python-format
+msgid "Server listening on port %d"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:77
-msgid "Install python-opencv to use tools."
+#: usr/share/biglinux/bigcam/ui/window.py:668
+msgid "Settings"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:104
-msgid "Smile Capture"
+#: usr/share/biglinux/bigcam/core/effects.py:295
+msgid "Sharpen"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:108
-msgid "Capture on Smile"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:28
+msgid "Sharpness"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:109
-msgid "Automatically take a photo when a smile is detected"
+#: usr/share/biglinux/bigcam/ui/window.py:1910
+#: usr/share/biglinux/bigcam/ui/window.py:2276
+msgid "Show"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:115
-msgid "Sensitivity"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:263
+#: usr/share/biglinux/bigcam/ui/settings_page.py:268
+msgid "Show FPS counter"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:324
-#: usr/share/biglinux/bigcam/ui/tools_page.py:408
-msgid "Watching for smiles..."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:275
+msgid "Show a rule-of-thirds grid over the preview."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:384
-msgid "Smile detected! Capturing..."
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:143
+msgid "Show dialog on startup"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:396
-#: usr/share/biglinux/bigcam/ui/window.py:1775
-#: usr/share/biglinux/bigcam/ui/window.py:1848
-msgid "Photo saved!"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:173
+#: usr/share/biglinux/bigcam/ui/settings_page.py:181
+msgid "Show help on hover"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/tools_page.py:399
-msgid "Capture failed."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:174
+msgid "Show tooltip hints when hovering over buttons."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:75
-msgid "Recorded Videos"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:311
+msgid "Show volume controls"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:155
-msgid "No videos yet"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:229
+#, python-format
+msgid "Showing %(shown)d of %(total)d items"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:156
-msgid "Recorded videos will appear here."
+#: usr/share/biglinux/bigcam/ui/tools_page.py:104
+msgid "Smile Capture"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:425
-#, python-format
-msgid "Delete %d videos?"
+#: usr/share/biglinux/bigcam/ui/tools_page.py:384
+msgid "Smile detected! Capturing..."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:426
-msgid "These videos will be permanently deleted."
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:283
+msgid "Social Network"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/video_gallery.py:520
-msgid "Delete video?"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:411
+msgid "Some profile controls could not be applied."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:53
-msgid "Virtual camera status"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:9
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:313
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:517
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:621
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:733
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:411
+msgid "Start"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:68
-msgid "Module"
+#: usr/share/biglinux/bigcam/ui/window.py:699
+#: usr/share/biglinux/bigcam/ui/window.py:2148
+msgid "Start recording"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:69
-msgid "v4l2loopback"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:688
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1282
+msgid "Start to see the address"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:99
-msgid "Usage"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1944
+msgid "Starting AirPlay…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:101
-msgid ""
-"When enabled, the active camera preview is sent to a virtual camera device "
-"that applications like OBS Studio, Google Meet, and Zoom can use."
+#: usr/share/biglinux/bigcam/ui/window.py:1304
+#: usr/share/biglinux/bigcam/ui/window.py:1411
+msgid "Starting camera stream…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:131
-msgid "Not installed"
+#: usr/share/biglinux/bigcam/ui/window.py:2128
+msgid "Starting recording…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:132
-msgid "—"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1227
+msgid "Starting server…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:143
-#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:149
-msgid "Loaded"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1461
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1830
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1889
+msgid "Starting…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:47
-msgid "Welcome to BigCam"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:29
+#: usr/share/biglinux/bigcam/ui/settings_page.py:555
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:43
+msgid "Status"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:55
-msgid "Your universal webcam control center for Linux"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:9
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:314
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:518
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:622
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:734
+msgid "Stop"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:72
-msgid "Photo & Video Capture"
+#: usr/share/biglinux/bigcam/ui/window.py:2400
+msgid "Stop camera and close"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:73
-msgid ""
-"Take photos and record videos\n"
-"with timer and countdown support"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:677
+#: usr/share/biglinux/bigcam/ui/preview_area.py:682
+#: usr/share/biglinux/bigcam/ui/window.py:2148
+msgid "Stop recording"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:77
-msgid "Mirror Preview"
+#: usr/share/biglinux/bigcam/ui/window.py:1376
+msgid "Streaming failed"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:78
-msgid ""
-"Flip the camera preview\n"
-"horizontally like a mirror"
+#: usr/share/biglinux/bigcam/core/effects.py:299
+#: usr/share/biglinux/bigcam/core/effects.py:311
+#: usr/share/biglinux/bigcam/core/effects.py:376
+msgid "Strength"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:82
-msgid "Real-Time Effects"
+#: usr/share/biglinux/bigcam/core/effects.py:364
+msgid "Style"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:83
-msgid ""
-"Apply brightness, contrast, blur,\n"
-"sepia, vignette and more effects live"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:420
+msgid "Subject"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:88
-msgid ""
-"Use your smartphone camera\n"
-"wirelessly as a webcam"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:410
+msgid "Summary"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:95
-msgid "Multiple Cameras"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:112
+msgid "Suppress resource usage warnings permanently"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:96
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:72
msgid ""
"Switch between USB, IP, and\n"
"virtual cameras with hotplug support"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:101
-msgid ""
-"Scan QR codes and barcodes\n"
-"directly from the camera feed"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:9
+msgid "Switch camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:106
-msgid ""
-"Create a virtual camera device\n"
-"for use in video calls"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:370
+msgid "Switch device to Wi-Fi mode"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:128
-msgid "Tip: Press Space to capture, Ctrl+R to record, Tab to toggle sidebar"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1734
+msgid "Switching to Wi-Fi…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:149
-msgid "Show dialog on startup"
+#: usr/share/biglinux/bigcam/ui/window.py:1784
+msgid "Switching to photography mode."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:160
-msgid "Let's Start"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:146
+msgid "System"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:153
-msgid "Use your phone as a webcam"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:69
+msgid ""
+"Take photos and record videos\n"
+"with timer and countdown support"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:172
-#: usr/share/biglinux/bigcam/ui/window.py:1901
-msgid "Phone camera status"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1001
+msgid "Tap 'Build Number' 7 times to unlock Developer Options"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:255
-#: usr/share/biglinux/bigcam/ui/window.py:619
-#: usr/share/biglinux/bigcam/ui/window.py:718
-msgid "Controls"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:966
+msgid "Tap 'Pair device with pairing code'"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:265
-msgid "Always on Top"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1035
+msgid "Tap 'Screen Mirroring' and select 'BigCam'"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:310
-msgid "Refresh cameras"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:928
+msgid "Tap 'Start' on the phone's browser page"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:312
-msgid "Refresh camera list"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:295
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:394
+msgid "Text"
+msgstr ""
+
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:674
+msgid "Text Files"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:322
-msgid "Menu"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:85
+#, python-format
+msgid "The application is using %s and %s."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:324
-msgid "Main menu"
+#: usr/share/biglinux/bigcam/ui/window.py:1955
+#, python-format
+msgid ""
+"The camera \"%(camera)s\" is being used by: %(apps)s.\n"
+"\n"
+"Close the other application or force-close it to free the camera."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:368
-#: usr/share/biglinux/bigcam/ui/window.py:370
-msgid "Photo mode"
+#: usr/share/biglinux/bigcam/ui/window.py:1960
+#, python-format
+msgid ""
+"The camera \"%s\" is being used by another application.\n"
+"\n"
+"Close the other application to free the camera."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:377
-#: usr/share/biglinux/bigcam/ui/window.py:379
-msgid "Video mode"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:549
+msgid "The camera could not apply this setting."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:406
-#: usr/share/biglinux/bigcam/ui/window.py:817
-#: usr/share/biglinux/bigcam/ui/window.py:823
-msgid "Last photo"
+#: usr/share/biglinux/bigcam/ui/window.py:1378
+msgid ""
+"The camera returned PTP errors during video capture. It may lack PC Remote "
+"mode or its USB connection mode needs to be changed. Check the camera menu "
+"for USB settings and select 'PC Remote' if available.\n"
+"\n"
+"Alternatively, use an HDMI capture card."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:408
-msgid "Open last photo"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:996
+msgid "The easiest and fastest method."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:448
-msgid "Enable Virtual Camera"
+#: usr/share/biglinux/bigcam/ui/window.py:1835
+#, python-brace-format
+msgid ""
+"The maximum limit of {} virtual cameras has been reached. Please disconnect "
+"some cameras or disable background virtual cameras in settings to connect a "
+"new one."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:515
-msgid "Zoom level"
+#: usr/share/biglinux/bigcam/core/phone_camera.py:134
+msgid "The previous server session is still stopping."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:517
-msgid "Change zoom level"
+#: usr/share/biglinux/bigcam/ui/about_dialog.py:39
+msgid ""
+"The universal webcam control center for Linux.\n"
+"\n"
+"BigCam was born as a small shell script so that Rafael Ruscher could use his "
+"Canon Rebel T3 as a webcam during live streams about BigLinux. That humble "
+"hack, written by Rafael and Barnabé di Kartola, evolved from a Bash bridge "
+"between gPhoto2 and FFmpeg into a full GTK4/Adwaita application with live "
+"preview, multi-backend camera support (V4L2, gPhoto2, libcamera, PipeWire, "
+"IP cameras, smartphones), real-time OpenCV effects, virtual camera output, "
+"photo and video capture, and 29 languages."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:530
-msgid "Toggle grid overlay"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:143
+msgid "Theme"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:570
-msgid "Fullscreen (F11)"
+#: usr/share/biglinux/bigcam/core/phone_strings.py:17
+msgid "This address is no longer valid. Scan the current QR code in BigCam."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:628
-#: usr/share/biglinux/bigcam/ui/window.py:719
-msgid "Effects"
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:188
+msgid "This camera does not expose any controls."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:637
-#: usr/share/biglinux/bigcam/ui/window.py:720
-msgid "Photos"
+#: usr/share/biglinux/bigcam/ui/window.py:1365
+msgid ""
+"This camera does not support live streaming via USB. Its PTP driver only "
+"allows file transfer. Use an HDMI capture card to stream from this camera."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:646
-#: usr/share/biglinux/bigcam/ui/window.py:721
-msgid "Videos"
+#: usr/share/biglinux/bigcam/core/effects.py:351
+msgid "Threshold 1"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:678
-#: usr/share/biglinux/bigcam/ui/window.py:722
-msgid "Settings"
+#: usr/share/biglinux/bigcam/core/effects.py:352
+msgid "Threshold 2"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:701
-msgid "Close sidebar"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:41
+msgid "Tilt"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:781
-#: usr/share/biglinux/bigcam/ui/window.py:2404
-msgid "Start recording"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:122
+msgid "Tip: Press Ctrl+P to capture, Ctrl+R to record, Tab to toggle sidebar"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:815
-#: usr/share/biglinux/bigcam/ui/window.py:820
-msgid "Last video"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:402
+msgid "Title"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:982
-#: usr/share/biglinux/bigcam/ui/window.py:1096
-msgid "Capture timer: Off"
+#: usr/share/biglinux/bigcam/ui/window.py:554
+msgid "Toggle grid overlay"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:986
-#: usr/share/biglinux/bigcam/ui/window.py:1100
-#, python-format
-msgid "Capture timer: %ds"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:321
+msgid "Transparency of the buttons over the preview."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1157
-msgid "Capture Photo"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:89
+#: usr/share/biglinux/bigcam/ui/preview_area.py:95
+#: usr/share/biglinux/bigcam/ui/window.py:1731
+#: usr/share/biglinux/bigcam/ui/window.py:1966
+msgid "Try again"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1158
-msgid "Record Video"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:970
+msgid "Type the IP:Port and code, then tap 'Pair'"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1163
-msgid "Load Profile"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:419
+msgid "URI"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1167
-msgid "Add IP Camera…"
+#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:50
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:687
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:287
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:393
+msgid "URL"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1168
-msgid "Phone as Webcam…"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:130
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1011
+msgid "USB"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1170
-msgid "Welcome Screen"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:995
+msgid "USB (Android)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1171
-msgid "About"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:160
+#: usr/share/biglinux/bigcam/ui/settings_page.py:166
+msgid "USB hotplug detection"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1175
-msgid "Quit"
+#: usr/share/biglinux/bigcam/core/stream_engine.py:1656
+msgid "Unknown GStreamer error"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1404
-#: usr/share/biglinux/bigcam/ui/window.py:1475
-msgid "Starting camera stream…"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:458
+#: usr/share/biglinux/bigcam/ui/preview_area.py:463
+msgid "Unmute"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1441
-msgid "Camera streaming started!"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:99
+msgid "Usage"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1461
-msgid "Failed to start camera streaming."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:967
+msgid "Use 'pairing CODE', NOT 'QR Code'"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1504
-#, python-brace-format
-msgid "Virtual Camera: {vcam_device} Created!"
+#: usr/share/biglinux/bigcam/ui/window.py:166
+msgid "Use your phone as a webcam"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1684
-msgid "QR Code detected!"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:93
+msgid ""
+"Use your smartphone camera\n"
+"wirelessly as a webcam"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1690
-#: usr/share/biglinux/bigcam/ui/window.py:2415
-msgid "No camera selected."
+#: usr/share/biglinux/bigcam/ui/window.py:924
+msgid "Use your window manager to keep this window above others."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1698
-msgid "Choose capture mode"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:518
+msgid "Video Bitrate (kbps)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1700
-msgid ""
-"You can take a screenshot from the current preview or capture a full-"
-"resolution photo directly from the camera."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:424
+msgid "Video Codec"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1704
-msgid "Preview screenshot"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:127
+msgid "Video directory"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1705
-msgid "Camera photo (full resolution)"
+#: usr/share/biglinux/bigcam/ui/window.py:2582
+msgid "Video effects"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1733
-msgid "Camera in video mode"
+#: usr/share/biglinux/bigcam/ui/window.py:389
+#: usr/share/biglinux/bigcam/ui/window.py:391
+msgid "Video mode"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1735
-msgid ""
-"Your camera is set to Video/Movie mode. Some cameras cannot take photos in "
-"this mode.\n"
-"\n"
-"Switch the mode dial on your camera to a photo mode (P, Av, Tv, M or Auto) "
-"and try again, or capture a frame from the current preview."
+#: usr/share/biglinux/bigcam/ui/window.py:2610
+msgid "Video recording"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1742
-msgid "Capture preview frame"
+#: usr/share/biglinux/bigcam/ui/window.py:2164
+#, python-format
+msgid "Video saved: %s"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1763
-msgid "Capturing photo…"
+#: usr/share/biglinux/bigcam/ui/window.py:667
+msgid "Videos"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1780
-#: usr/share/biglinux/bigcam/ui/window.py:1853
-msgid "Failed to capture photo."
+#: usr/share/biglinux/bigcam/core/effects.py:372
+msgid "Vignette"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1793
-msgid "Switching to photography mode."
+#: usr/share/biglinux/bigcam/ui/settings_page.py:553
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:40
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:87
+msgid "Virtual Camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1859
-msgid "Resuming camera streaming…"
+#: usr/share/biglinux/bigcam/ui/window.py:1834
+msgid "Virtual Camera Limit Reached"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1895
-msgid "Phone camera: waiting"
+#: usr/share/biglinux/bigcam/ui/window.py:2078
+msgid "Virtual Camera enabled. Other applications can use /dev/video10."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1896
-msgid "Phone camera: connected"
+#: usr/share/biglinux/bigcam/ui/window.py:2390
+msgid "Virtual Camera is enabled (other apps may depend on it)."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:1897
-msgid "Phone camera: stopped"
+#: usr/share/biglinux/bigcam/ui/window.py:1440
+#, python-brace-format
+msgid "Virtual Camera: {vcam_device} Created!"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2045
-msgid "Phone Mic (Browser)"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:578
+msgid "Virtual Cameras"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2052
-#: usr/share/biglinux/bigcam/ui/window.py:2119
-#: usr/share/biglinux/bigcam/ui/window.py:2181
-#: usr/share/biglinux/bigcam/ui/window.py:2539
-msgid "Show"
+#: usr/share/biglinux/bigcam/ui/window.py:2591
+msgid "Virtual camera"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2103
-msgid "BigCam Phone (AirPlay)"
+#: usr/share/biglinux/bigcam/ui/window.py:2602
+msgid "Virtual camera feeds for inactive cameras"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2114
-msgid "AirPlay Audio"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:53
+msgid "Virtual camera status"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2165
-msgid "BigCam Phone (scrcpy)"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:574
+msgid "Visible name"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2176
-msgid "Phone Mic (scrcpy)"
+#: usr/share/biglinux/bigcam/ui/preview_area.py:310
+msgid "Volume"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2256
-#, python-format
-msgid ""
-"The camera \"%(camera)s\" is being used by: %(apps)s.\n"
-"\n"
-"Close the other application or force-close it to free the camera."
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:44
+msgid "WB Preset"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2261
-#, python-format
-msgid ""
-"The camera \"%s\" is being used by another application.\n"
-"\n"
-"Close the other application to free the camera."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:2005
+msgid "Waiting for AirPlay connection…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2265
-msgid "Camera in use"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1196
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1245
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1323
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1335
+msgid "Waiting for connection…"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2276
-#, python-format
-msgid "Force close %s"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:189
+msgid "Warn when CPU or memory usage is high."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2282
-msgid "Close"
+#: usr/share/biglinux/bigcam/ui/tools_page.py:324
+#: usr/share/biglinux/bigcam/ui/tools_page.py:408
+msgid "Watching for smiles..."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2379
-msgid "Virtual Camera enabled. Other applications can use /dev/video10."
+#: usr/share/biglinux/bigcam/ui/window.py:1052
+msgid "Welcome Screen"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2407
-#, python-format
-msgid "Video saved: %s"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:47
+msgid "Welcome to BigCam"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2441
-msgid "Recording…"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:472
+msgid "WhatsApp"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2445
-msgid "Failed to start recording."
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:101
+msgid ""
+"When enabled, the active camera preview is sent to a virtual camera device "
+"that applications like OBS Studio, Google Meet, and Zoom can use."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2482
-msgid "Profile saved."
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:27
+msgid "White Balance"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2492
-msgid "No profiles found."
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:31
+msgid "White Balance Temperature"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2504
-#, python-format
-msgid "Profile loaded: %s"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:122
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:979
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:155
+msgid "Wi-Fi"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2626
-#, python-format
-msgid "Active camera: %s"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:950
+msgid "Wi-Fi (Android 11+)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2628
-msgid "Virtual Camera is enabled (other apps may depend on it)."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1750
+msgid "Wi-Fi switch failed"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2631
-msgid ""
-"If you choose to keep it running, the camera will remain on after closing "
-"the application."
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1745
+#, python-format
+msgid "Wi-Fi: %s (unplug USB)"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2637
-msgid "Camera is active"
+#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:920
+msgid "Works with any phone, no app required."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2638
-msgid "Stop camera and close"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:95
+msgid ""
+"You can optimize by disabling the heaviest features, or continue if you "
+"understand the impact."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2639
-msgid "Keep camera on"
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:250
+msgid ""
+"You can restore these files from the system Trash. Files that cannot be "
+"trashed will be kept."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2774
-msgid "Video effects"
+#: usr/share/biglinux/bigcam/ui/window.py:1685
+msgid ""
+"You can take a screenshot from the current preview or capture a full-"
+"resolution photo directly from the camera."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2775
-msgid "Real-time filters, background blur, artistic effects"
+#: usr/share/biglinux/bigcam/ui/window.py:1723
+msgid ""
+"Your camera is set to Video/Movie mode. Some cameras cannot take photos in "
+"this mode.\n"
+"\n"
+"Switch the mode dial on your camera to a photo mode (P, Av, Tv, M or Auto) "
+"and try again, or capture a frame from the current preview."
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2783
-msgid "Virtual camera"
+#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:55
+msgid "Your universal webcam control center for Linux"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2784
-msgid "v4l2loopback output for video conferencing"
+#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:39
+msgid "Zoom"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2792
-msgid "Background virtual cameras"
+#: usr/share/biglinux/bigcam/ui/window.py:539
+msgid "Zoom level"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2793
-msgid "Virtual camera feeds for inactive cameras"
+#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:90
+msgid "active source"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2801
-msgid "Video recording"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:643
+msgid "iCalendar Files"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2802
-msgid "Active video recording with encoding"
+#: usr/share/biglinux/bigcam/core/phone_camera.py:127
+msgid "python-aiohttp is not installed"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2813
-msgid "Phone camera server"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:69
+msgid "v4l2loopback"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2814
-msgid "HTTPS/WebSocket server for phone camera"
+#: usr/share/biglinux/bigcam/ui/settings_page.py:975
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:135
+msgid "v4l2loopback not available"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2823
-msgid "Scrcpy (Android camera)"
+#: usr/share/biglinux/bigcam/ui/window.py:2592
+msgid "v4l2loopback output for video conferencing"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2824
-msgid "Android camera via USB/Wi-Fi ADB"
+#: usr/share/biglinux/bigcam/ui/qr_dialog.py:669
+msgid "vCard Files"
msgstr ""
-#: usr/share/biglinux/bigcam/ui/window.py:2833
-msgid "AirPlay receiver"
-msgstr ""
+#: usr/share/biglinux/bigcam/ui/preview_area.py:731
+#: usr/share/biglinux/bigcam/ui/preview_area.py:745
+#, python-brace-format
+msgid "{n} second remaining"
+msgid_plural "{n} seconds remaining"
+msgstr[0] ""
+msgstr[1] ""
-#: usr/share/biglinux/bigcam/ui/window.py:2834
-msgid "Apple AirPlay screen mirroring via UxPlay"
+#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:138
+msgid "—"
msgstr ""
diff --git a/usr/share/locale/pt-BR.po b/usr/share/locale/pt-BR.po
index 586c722..9925eb8 100644
--- a/usr/share/locale/pt-BR.po
+++ b/usr/share/locale/pt-BR.po
@@ -4,12 +4,14 @@ msgstr ""
"Project-Id-Version: bigcam\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-26 14:24-0300\n"
+"PO-Revision-Date: 2026-09-07 23:50-0300\n"
"Last-Translator: Translation Automator \n"
"Language-Team: Portuguese (Brazil) \n"
"Language: pt-BR\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
+"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py:232
msgid "Generic Camera"
@@ -246,24 +248,11 @@ msgstr "Erro desconhecido do GStreamer"
msgid ""
"The universal webcam control center for Linux.\n"
"\n"
-"BigCam was born as a small shell script so that Rafael Ruscher could use his "
-"Canon Rebel T3 as a webcam during live streams about BigLinux. That humble "
-"hack, written by Rafael and Barnabé di Kartola, evolved from a Bash bridge "
-"between gPhoto2 and FFmpeg into a full GTK4/Adwaita application with live "
-"preview, multi-backend camera support (V4L2, gPhoto2, libcamera, PipeWire, "
-"IP cameras, smartphones), real-time OpenCV effects, virtual camera output, "
-"photo and video capture, and 29 languages."
+"BigCam was born as a small shell script so that Rafael Ruscher could use his Canon Rebel T3 as a webcam during live streams about BigLinux. That humble hack, written by Rafael and Barnabé di Kartola, evolved from a Bash bridge between gPhoto2 and FFmpeg into a full GTK4/Adwaita application with live preview, multi-backend camera support (V4L2, gPhoto2, libcamera, PipeWire, IP cameras, smartphones), real-time OpenCV effects, virtual camera output, photo and video capture, and 29 languages."
msgstr ""
"O centro universal de controle de webcam para Linux.\n"
"\n"
-"BigCam nasceu como um pequeno script shell para que Rafael Ruscher pudesse "
-"usar sua Canon Rebel T3 como webcam durante transmissões ao vivo sobre o "
-"BigLinux. Esse hack humilde, escrito por Rafael e Barnabé di Kartola, "
-"evoluiu de uma ponte Bash entre gPhoto2 e FFmpeg para um aplicativo completo "
-"GTK4/Adwaita com visualização ao vivo, suporte a múltiplos backends de "
-"câmera (V4L2, gPhoto2, libcamera, PipeWire, câmeras IP, smartphones), "
-"efeitos OpenCV em tempo real, saída de câmera virtual, captura de fotos e "
-"vídeos, e 29 idiomas."
+"BigCam nasceu como um pequeno script shell para que Rafael Ruscher pudesse usar sua Canon Rebel T3 como webcam durante transmissões ao vivo sobre o BigLinux. Esse hack humilde, escrito por Rafael e Barnabé di Kartola, evoluiu de uma ponte Bash entre gPhoto2 e FFmpeg para um aplicativo completo GTK4/Adwaita com visualização ao vivo, suporte a múltiplos backends de câmera (V4L2, gPhoto2, libcamera, PipeWire, câmeras IP, smartphones), efeitos OpenCV em tempo real, saída de câmera virtual, captura de fotos e vídeos, e 29 idiomas."
#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:22
msgid "Image"
@@ -771,8 +760,8 @@ msgstr "Método rápido"
#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:953
msgid ""
-"If connected via USB, use the wireless icon on the device selector to switch "
-"to Wi-Fi instantly."
+"If connected via USB, use the wireless icon on the device selector to switch"
+" to Wi-Fi instantly."
msgstr ""
"Se conectado via USB, use o ícone sem fio no seletor de dispositivos para "
"mudar para Wi-Fi instantaneamente."
@@ -874,7 +863,8 @@ msgstr "Clique em 'Iniciar' na aba AirPlay"
#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1031
msgid "On your iPhone, open Control Center (swipe down from top-right)"
msgstr ""
-"No seu iPhone, abra a Central de Controle (deslize do canto superior direito)"
+"No seu iPhone, abra a Central de Controle (deslize do canto superior "
+"direito)"
#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1032
msgid "Tap 'Screen Mirroring' and select 'BigCam'"
@@ -925,8 +915,8 @@ msgid ""
"No device found. Connect via USB cable and enable USB Debugging, then tap "
"'Refresh'."
msgstr ""
-"Nenhum dispositivo encontrado. Conecte via cabo USB e ative a Depuração USB, "
-"depois toque em 'Atualizar'."
+"Nenhum dispositivo encontrado. Conecte via cabo USB e ative a Depuração USB,"
+" depois toque em 'Atualizar'."
#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1455
msgid "Selected device is no longer available. Tap 'Refresh' to update."
@@ -998,8 +988,8 @@ msgid ""
"Not found. Make sure you tapped 'Pair with pairing code' (not QR Code) and "
"that the code screen is still open. You can also type the IP:Port manually."
msgstr ""
-"Não encontrado. Certifique-se de que você tocou em 'Emparelhar com código de "
-"pareamento' (não QR Code) e que a tela do código ainda está aberta. Você "
+"Não encontrado. Certifique-se de que você tocou em 'Emparelhar com código de"
+" pareamento' (não QR Code) e que a tela do código ainda está aberta. Você "
"também pode digitar o IP:Port manualmente."
#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1662
@@ -1053,8 +1043,8 @@ msgstr ""
#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1829
msgid "Selected device is no longer available. Tap 'Scan' to refresh."
msgstr ""
-"O dispositivo selecionado não está mais disponível. Toque em 'Escanear' para "
-"atualizar."
+"O dispositivo selecionado não está mais disponível. Toque em 'Escanear' para"
+" atualizar."
#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1902
#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1941
@@ -1705,8 +1695,8 @@ msgid ""
"Bypass PipeWire and access the camera directly. May fix flickering on some "
"webcams."
msgstr ""
-"Ignorar PipeWire e acessar a câmera diretamente. Pode corrigir cintilação em "
-"algumas webcams."
+"Ignorar PipeWire e acessar a câmera diretamente. Pode corrigir cintilação em"
+" algumas webcams."
#: usr/share/biglinux/bigcam/ui/settings_page.py:318
msgid "Camera"
@@ -1917,8 +1907,8 @@ msgid ""
"that applications like OBS Studio, Google Meet, and Zoom can use."
msgstr ""
"Quando ativado, a pré-visualização da câmera ativa é enviada para um "
-"dispositivo de câmera virtual que aplicativos como OBS Studio, Google Meet e "
-"Zoom podem usar."
+"dispositivo de câmera virtual que aplicativos como OBS Studio, Google Meet e"
+" Zoom podem usar."
#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:131
msgid "Not installed"
@@ -2232,17 +2222,13 @@ msgstr "Câmera no modo vídeo"
#: usr/share/biglinux/bigcam/ui/window.py:1735
msgid ""
-"Your camera is set to Video/Movie mode. Some cameras cannot take photos in "
-"this mode.\n"
+"Your camera is set to Video/Movie mode. Some cameras cannot take photos in this mode.\n"
"\n"
-"Switch the mode dial on your camera to a photo mode (P, Av, Tv, M or Auto) "
-"and try again, or capture a frame from the current preview."
+"Switch the mode dial on your camera to a photo mode (P, Av, Tv, M or Auto) and try again, or capture a frame from the current preview."
msgstr ""
-"Sua câmera está configurada para o modo Vídeo/Filme. Algumas câmeras não "
-"conseguem tirar fotos nesse modo.\n"
+"Sua câmera está configurada para o modo Vídeo/Filme. Algumas câmeras não conseguem tirar fotos nesse modo.\n"
"\n"
-"Altere o seletor de modo da sua câmera para um modo de foto (P, Av, Tv, M ou "
-"Auto) e tente novamente, ou capture um quadro da visualização atual."
+"Altere o seletor de modo da sua câmera para um modo de foto (P, Av, Tv, M ou Auto) e tente novamente, ou capture um quadro da visualização atual."
#: usr/share/biglinux/bigcam/ui/window.py:1742
msgid "Capture preview frame"
@@ -2455,9 +2441,478 @@ msgstr "Receptor AirPlay"
msgid "Apple AirPlay screen mirroring via UxPlay"
msgstr "Espelhamento de tela Apple AirPlay via UxPlay"
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py:95
+msgid "Advanced Controls"
+msgstr "Controles Avançados"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py:96
+msgid ""
+"Fine-tune exposure, white balance\n"
+"and save per-camera profiles"
+msgstr ""
+"Ajuste fino da exposição, balanço de branco\n"
+"e salve perfis por câmera"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py:122
+msgid "Tip: Press Ctrl+P to capture, Ctrl+R to record, Tab to toggle sidebar"
+msgstr ""
+"Dica: Pressione Ctrl+P para capturar, Ctrl+R para gravar, Tab para alternar "
+"a barra lateral"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/settings_page.py:533
+msgid "Maximum virtual cameras"
+msgstr "Máximo de câmeras virtuais"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/settings_page.py:534
+msgid "How many virtual camera devices to create (one per camera)."
+msgstr "Quantos dispositivos de câmera virtual criar (um por câmera)."
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/settings_page.py:543
+msgid "Device name"
+msgstr "Nome do dispositivo"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/settings_page.py:548
+msgid ""
+"Name template for virtual cameras. Devices will be named ' 1', ' 2', etc.\n"
+"Press Enter or click ✓ to apply."
+msgstr ""
+"Modelo de nome para câmeras virtuais. Os dispositivos serão nomeados como ' 1', ' 2', etc.\n"
+"Pressione Enter ou clique em ✓ para aplicar."
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:787
+msgid "Instructions"
+msgstr "Instruções"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/window.py:1477
+msgid "Capture not supported"
+msgstr "Captura não suportada"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/window.py:1479
+msgid ""
+"This camera does not support live streaming via USB. Its PTP driver only "
+"allows file transfer. Use an HDMI capture card to stream from this camera."
+msgstr ""
+"Esta câmera não suporta transmissão ao vivo via USB. Seu driver PTP permite "
+"apenas transferência de arquivos. Use uma placa de captura HDMI para "
+"transmitir desta câmera."
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/window.py:1486
+msgid "Camera does not support USB streaming."
+msgstr "A câmera não suporta transmissão via USB."
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/window.py:1490
+msgid "Streaming failed"
+msgstr "Falha na transmissão"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/window.py:1492
+msgid ""
+"The camera returned PTP errors during video capture. It may lack PC Remote mode or its USB connection mode needs to be changed. Check the camera menu for USB settings and select 'PC Remote' if available.\n"
+"\n"
+"Alternatively, use an HDMI capture card."
+msgstr ""
+"A câmera retornou erros PTP durante a captura de vídeo. Pode não ter modo PC Remote ou o modo de conexão USB precisa ser alterado. Verifique o menu da câmera para configurações USB e selecione 'PC Remote' se disponível.\n"
+"\n"
+"Alternativamente, use uma placa de captura HDMI."
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/window.py:1501
+msgid "Camera PTP streaming failed."
+msgstr "Falha na transmissão PTP da câmera."
+
+#. yellow/warning
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/settings_page.py:891
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py:133
+msgid "Reboot required (kernel updated)"
+msgstr "Reinicialização necessária (kernel atualizado)"
+
+#: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py:131
+msgid "Module not available for current kernel"
+msgstr "Módulo não disponível para o kernel atual"
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:377
+#, python-format
+msgid "%(source)s — audio %(index)d"
+msgstr "%(source)s — áudio %(index)d"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:271
+#, python-format
+msgid "%d file could not be moved to Trash."
+msgid_plural "%d files could not be moved to Trash."
+msgstr[0] "Não foi possível mover %d arquivo para a lixeira."
+msgstr[1] "Não foi possível mover %d arquivos para a lixeira."
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:227
+#, python-format
+msgid "%d item selected"
+msgid_plural "%d items selected"
+msgstr[0] "%d item selecionado"
+msgstr[1] "%d itens selecionados"
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:735
+#, python-format
+msgid "%d second remaining"
+msgid_plural "%d seconds remaining"
+msgstr[0] "%d segundo restante"
+msgstr[1] "%d segundos restantes"
+
+#: usr/share/biglinux/bigcam/ui/window.py:870
+#, python-format
+msgid "%d virtual camera allocated"
+msgid_plural "%d virtual cameras allocated"
+msgstr[0] "%d câmera virtual alocada"
+msgstr[1] "%d câmeras virtuais alocadas"
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:408
+#, python-format
+msgid "%s — volume"
+msgstr "%s — volume"
+
+#: usr/share/biglinux/bigcam/ui/window.py:2388
+msgid "Active cameras:"
+msgstr "Câmeras ativas:"
+
+#: usr/share/biglinux/bigcam/core/camera_manager.py:230
+msgid "Audio Volume"
+msgstr "Volume do áudio"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:204
+#: usr/share/biglinux/bigcam/ui/settings_page.py:212
+msgid "Auto-optimize resources"
+msgstr "Otimizar recursos automaticamente"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:205
+msgid "Automatically disable heavy background features when usage is high."
+msgstr ""
+"Desativar automaticamente recursos pesados em segundo plano quando o uso "
+"estiver alto."
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:237
+msgid "Automatically hide camera controls"
+msgstr "Ocultar os controles da câmera automaticamente"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:236
+msgid "Avoid capture flashes and decorative animations."
+msgstr "Evitar flashes de captura e animações decorativas."
+
+#: usr/share/biglinux/bigcam/core/camera_manager.py:144
+msgid "Camera detection failed. Try refreshing the camera list."
+msgstr "Falha ao detectar câmeras. Tente atualizar a lista de câmeras."
+
+#: usr/share/biglinux/bigcam/ui/window.py:1514
+#: usr/share/biglinux/bigcam/ui/window.py:1523
+msgid "Camera preferences will apply after recording stops."
+msgstr "As preferências da câmera serão aplicadas após o fim da gravação."
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:13
+msgid "Camera preview"
+msgstr "Prévia da câmera"
+
+#: usr/share/biglinux/bigcam/ui/window.py:917
+msgid "Capture cancelled."
+msgstr "Captura cancelada."
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:153
+msgid "Check the connection and try again."
+msgstr "Verifique a conexão e tente novamente."
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:8
+msgid "Connecting…"
+msgstr "Conectando…"
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:15
+msgid ""
+"Connection failed. Check permissions, the address and whether another device"
+" is connected."
+msgstr ""
+"Falha na conexão. Verifique as permissões, o endereço e se outro dispositivo"
+" está conectado."
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:237
+msgid "Controls remain visible while using keyboard focus."
+msgstr "Os controles permanecem visíveis durante a navegação pelo teclado."
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:152
+msgid "Could not load camera controls"
+msgstr "Não foi possível carregar os controles da câmera"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:272
+msgid "Could not move files to Trash."
+msgstr "Não foi possível mover os arquivos para a lixeira."
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:240
+msgid "Could not open the file or folder."
+msgstr "Não foi possível abrir o arquivo ou a pasta."
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:114
+msgid "Could not read the media folder."
+msgstr "Não foi possível ler a pasta de mídia."
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:594
+msgid "Could not reset camera controls."
+msgstr "Não foi possível restaurar os controles da câmera."
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:444
+msgid "Could not save the profile. Check its name and folder permissions."
+msgstr ""
+"Não foi possível salvar o perfil. Verifique o nome e as permissões da pasta."
+
+#: usr/share/biglinux/bigcam/core/phone_camera.py:166
+#, python-format
+msgid "Could not start the camera server: %s"
+msgstr "Não foi possível iniciar o servidor da câmera: %s"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:568
+msgid "Enable virtual camera service"
+msgstr "Ativar o serviço de câmera virtual"
+
+#: usr/share/biglinux/bigcam/ui/window.py:1803
+msgid "Failed to capture photo. Check the camera mode and connection."
+msgstr "Falha ao capturar a foto. Verifique o modo da câmera e a conexão."
+
+#: usr/share/biglinux/bigcam/ui/window.py:2100
+#: usr/share/biglinux/bigcam/ui/window.py:2145
+msgid "Finalizing video…"
+msgstr "Finalizando vídeo…"
+
+#: usr/share/biglinux/bigcam/ui/window.py:1216
+msgid "Finish the current capture before changing cameras."
+msgstr "Conclua a captura atual antes de trocar de câmera."
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:11
+msgid "Frames per second"
+msgstr "Quadros por segundo"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:49
+msgid "Gallery status"
+msgstr "Estado da galeria"
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:12
+msgid "High"
+msgstr "Alta"
+
+#: usr/share/biglinux/bigcam/ui/window.py:2523
+msgid "High resource usage detected. Optimized automatically."
+msgstr "Detectado alto uso de recursos. Otimização automática aplicada."
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:13
+msgid "Include microphone audio"
+msgstr "Incluir áudio do microfone"
+
+#: usr/share/biglinux/bigcam/core/phone_camera.py:129
+msgid "Invalid server port"
+msgstr "Porta do servidor inválida"
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:523
+msgid "Live"
+msgstr "Ao vivo"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:62
+msgid "Load more"
+msgstr "Carregar mais"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:105
+msgid "Loading media…"
+msgstr "Carregando mídia…"
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:12
+msgid "Low"
+msgstr "Baixa"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:569
+msgid "Master switch to allow virtual camera outputs."
+msgstr "Controle geral para permitir saídas de câmera virtual."
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:12
+msgid "Medium"
+msgstr "Média"
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:16
+msgid ""
+"Microphone audio is unavailable. Stop and reconnect, or turn off microphone "
+"audio."
+msgstr ""
+"O áudio do microfone não está disponível. Pare e reconecte ou desative o "
+"áudio do microfone."
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:249
+#, python-format
+msgid "Move %d item to Trash?"
+msgid_plural "Move %d items to Trash?"
+msgstr[0] "Mover %d item para a lixeira?"
+msgstr[1] "Mover %d itens para a lixeira?"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:168
+#, python-format
+msgid "Move %s to Trash"
+msgstr "Mover %s para a lixeira"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:78
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:167
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:252
+msgid "Move to Trash"
+msgstr "Mover para a lixeira"
+
+#: usr/share/biglinux/bigcam/ui/window.py:2106
+msgid "No active camera stream."
+msgstr "Nenhuma transmissão de câmera ativa."
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:1009
+msgid "No cameras connected"
+msgstr "Nenhuma câmera conectada"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:232
+msgid "No media yet"
+msgstr "Ainda não há mídia"
+
+#: usr/share/biglinux/bigcam/ui/window.py:1837
+msgid "OK"
+msgstr "OK"
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:14
+msgid ""
+"Only connect on a trusted network. Anyone with this address can connect to "
+"the camera service."
+msgstr ""
+"Conecte apenas em uma rede confiável. Qualquer pessoa com este endereço pode"
+" acessar o serviço da câmera."
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:156
+#, python-format
+msgid "Open %s"
+msgstr "Abrir %s"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:69
+msgid "Open folder"
+msgstr "Abrir pasta"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:70
+msgid "Open media folder"
+msgstr "Abrir pasta de mídia"
+
+#: usr/share/biglinux/bigcam/ui/window.py:2378
+msgid "Phone (AirPlay)"
+msgstr "Celular (AirPlay)"
+
+#: usr/share/biglinux/bigcam/ui/window.py:2370
+msgid "Phone (Browser Wi-Fi)"
+msgstr "Celular (navegador por Wi-Fi)"
+
+#: usr/share/biglinux/bigcam/ui/window.py:2374
+msgid "Phone (USB/Wi-Fi scrcpy)"
+msgstr "Celular (scrcpy por USB/Wi-Fi)"
+
+#: usr/share/biglinux/bigcam/ui/window.py:2168
+#, python-format
+msgid "Recording failed. Any partial file has been preserved: %s"
+msgstr "Falha na gravação. O arquivo parcial, se houver, foi preservado: %s"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:236
+msgid "Reduce motion and flashes"
+msgstr "Reduzir movimento e flashes"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:66
+msgid "Refresh gallery"
+msgstr "Atualizar galeria"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:162
+msgid "Select"
+msgstr "Selecionar"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:163
+#, python-format
+msgid "Select %s"
+msgstr "Selecionar %s"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:75
+msgid "Select displayed items"
+msgstr "Selecionar itens exibidos"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:579
+msgid "Select which cameras should output to a virtual device."
+msgstr "Selecione quais câmeras devem transmitir para um dispositivo virtual."
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:311
+msgid "Show volume controls"
+msgstr "Mostrar controles de volume"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:229
+#, python-format
+msgid "Showing %(shown)d of %(total)d items"
+msgstr "Exibindo %(shown)d de %(total)d itens"
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:411
+msgid "Some profile controls could not be applied."
+msgstr "Não foi possível aplicar alguns controles do perfil."
+
+#: usr/share/biglinux/bigcam/ui/window.py:2128
+msgid "Starting recording…"
+msgstr "Iniciando gravação…"
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:9
+msgid "Switch camera"
+msgstr "Trocar câmera"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:146
+msgid "System"
+msgstr "Sistema"
+
+#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:549
+msgid "The camera could not apply this setting."
+msgstr "A câmera não conseguiu aplicar esta configuração."
+
+#: usr/share/biglinux/bigcam/ui/window.py:1835
+#, python-brace-format
+msgid ""
+"The maximum limit of {} virtual cameras has been reached. Please disconnect "
+"some cameras or disable background virtual cameras in settings to connect a "
+"new one."
+msgstr ""
+"O limite de {} câmeras virtuais foi atingido. Desconecte câmeras ou desative"
+" as câmeras virtuais em segundo plano nas configurações para conectar uma "
+"nova."
+
+#: usr/share/biglinux/bigcam/core/phone_camera.py:134
+msgid "The previous server session is still stopping."
+msgstr "A sessão anterior do servidor ainda está sendo encerrada."
+
+#: usr/share/biglinux/bigcam/core/phone_strings.py:17
+msgid "This address is no longer valid. Scan the current QR code in BigCam."
+msgstr "Este endereço não é mais válido. Leia o QR code atual no BigCam."
+
+#: usr/share/biglinux/bigcam/ui/window.py:924
+msgid "Use your window manager to keep this window above others."
+msgstr ""
+"Use o gerenciador de janelas para manter esta janela acima das outras."
+
+#: usr/share/biglinux/bigcam/ui/window.py:1834
+msgid "Virtual Camera Limit Reached"
+msgstr "Limite de câmeras virtuais atingido"
+
+#: usr/share/biglinux/bigcam/ui/settings_page.py:578
+msgid "Virtual Cameras"
+msgstr "Câmeras virtuais"
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:310
+msgid "Volume"
+msgstr "Volume"
+
+#: usr/share/biglinux/bigcam/ui/media_gallery.py:250
+msgid ""
+"You can restore these files from the system Trash. Files that cannot be "
+"trashed will be kept."
+msgstr ""
+"Você pode restaurar estes arquivos pela lixeira do sistema. Arquivos que não"
+" puderem ser movidos para a lixeira serão mantidos."
+
+#: usr/share/biglinux/bigcam/ui/preview_area.py:731
+#: usr/share/biglinux/bigcam/ui/preview_area.py:745
+#, python-brace-format
+msgid "{n} second remaining"
+msgid_plural "{n} seconds remaining"
+msgstr[0] "{n} segundo restante"
+msgstr[1] "{n} segundos restantes"
+
#~ msgid "1. On your Android phone, go to Settings → About Phone"
-#~ msgstr ""
-#~ "1. No seu telefone Android, vá para Configurações → Sobre o telefone"
+#~ msgstr "1. No seu telefone Android, vá para Configurações → Sobre o telefone"
#~ msgid "2. Tap 'Build Number' 7 times to unlock Developer Options"
#~ msgstr ""
@@ -2481,8 +2936,7 @@ msgstr "Espelhamento de tela Apple AirPlay via UxPlay"
#~ msgstr "MAIS FÁCIL: Se conectado via USB, use o ícone sem fio"
#~ msgid "on the device selector to switch to Wi-Fi instantly."
-#~ msgstr ""
-#~ "no seletor de dispositivo para alternar para Wi-Fi instantaneamente."
+#~ msgstr "no seletor de dispositivo para alternar para Wi-Fi instantaneamente."
#~ msgid "FIRST-TIME PAIRING (without USB):"
#~ msgstr "EMPARELHAMENTO PELA PRIMEIRA VEZ (sem USB):"
@@ -2528,8 +2982,8 @@ msgstr "Espelhamento de tela Apple AirPlay via UxPlay"
#~ msgid "3. On your iPhone, open Control Center (swipe down from top-right)"
#~ msgstr ""
-#~ "3. No seu iPhone, abra a Central de Controle (deslize para baixo a partir "
-#~ "do canto superior direito)"
+#~ "3. No seu iPhone, abra a Central de Controle (deslize para baixo a partir do"
+#~ " canto superior direito)"
#~ msgid "4. Tap 'Screen Mirroring' and select 'BigCam'"
#~ msgstr "4. Toque em 'Screen Mirroring' e selecione 'BigCam'"
@@ -2552,10 +3006,6 @@ msgstr "Espelhamento de tela Apple AirPlay via UxPlay"
#~ msgid "Refresh photo gallery"
#~ msgstr "Atualizar galeria de fotos"
-#, python-format
-#~ msgid "Open %s"
-#~ msgstr "Abrir %s"
-
#, python-format
#~ msgid "Delete %s"
#~ msgstr "Excluir %s"
@@ -2633,57 +3083,41 @@ msgstr "Espelhamento de tela Apple AirPlay via UxPlay"
#~ "\n"
#~ "Instale com: sudo pacman -S python-aiohttp"
-#~ msgid "OK"
-#~ msgstr "OK"
-
-#~ msgid "System"
-#~ msgstr "Sistema"
-
#, python-format
#~ msgid ""
#~ "The camera \"%(camera)s\" is being used by: %(apps)s.\n"
#~ "\n"
-#~ "The video preview cannot be displayed while another application has "
-#~ "exclusive access to this device.\n"
+#~ "The video preview cannot be displayed while another application has exclusive access to this device.\n"
#~ "\n"
#~ "Camera settings and controls will continue to work normally.\n"
#~ "\n"
-#~ "Tip: Enable the Virtual Camera to share the camera feed with multiple "
-#~ "applications simultaneously."
+#~ "Tip: Enable the Virtual Camera to share the camera feed with multiple applications simultaneously."
#~ msgstr ""
#~ "A câmera \"%(camera)s\" está sendo usada por: %(apps)s.\n"
#~ "\n"
-#~ "A pré-visualização do vídeo não pode ser exibida enquanto outro "
-#~ "aplicativo tem acesso exclusivo a este dispositivo.\n"
+#~ "A pré-visualização do vídeo não pode ser exibida enquanto outro aplicativo tem acesso exclusivo a este dispositivo.\n"
#~ "\n"
-#~ "As configurações e controles da câmera continuarão funcionando "
-#~ "normalmente.\n"
+#~ "As configurações e controles da câmera continuarão funcionando normalmente.\n"
#~ "\n"
-#~ "Dica: Ative a Câmera Virtual para compartilhar o feed da câmera com "
-#~ "vários aplicativos simultaneamente."
+#~ "Dica: Ative a Câmera Virtual para compartilhar o feed da câmera com vários aplicativos simultaneamente."
#, python-format
#~ msgid ""
#~ "The camera \"%s\" is being used by another application.\n"
#~ "\n"
-#~ "The video preview cannot be displayed while another application has "
-#~ "exclusive access to this device.\n"
+#~ "The video preview cannot be displayed while another application has exclusive access to this device.\n"
#~ "\n"
#~ "Camera settings and controls will continue to work normally.\n"
#~ "\n"
-#~ "Tip: Enable the Virtual Camera to share the camera feed with multiple "
-#~ "applications simultaneously."
+#~ "Tip: Enable the Virtual Camera to share the camera feed with multiple applications simultaneously."
#~ msgstr ""
#~ "A câmera \"%s\" está sendo usada por outro aplicativo.\n"
#~ "\n"
-#~ "A pré-visualização do vídeo não pode ser exibida enquanto outro "
-#~ "aplicativo tem acesso exclusivo a este dispositivo.\n"
+#~ "A pré-visualização do vídeo não pode ser exibida enquanto outro aplicativo tem acesso exclusivo a este dispositivo.\n"
#~ "\n"
-#~ "As configurações e controles da câmera continuarão funcionando "
-#~ "normalmente.\n"
+#~ "As configurações e controles da câmera continuarão funcionando normalmente.\n"
#~ "\n"
-#~ "Dica: Ative a Câmera Virtual para compartilhar o feed da câmera com "
-#~ "vários aplicativos simultaneamente."
+#~ "Dica: Ative a Câmera Virtual para compartilhar o feed da câmera com vários aplicativos simultaneamente."
#~ msgid "Backlight Compensation"
#~ msgstr "Compensação de Contraluz"
diff --git a/usr/share/locale/pt-BR/LC_MESSAGES/bigcam.mo b/usr/share/locale/pt-BR/LC_MESSAGES/bigcam.mo
index 8532b17..0eb6af0 100644
Binary files a/usr/share/locale/pt-BR/LC_MESSAGES/bigcam.mo and b/usr/share/locale/pt-BR/LC_MESSAGES/bigcam.mo differ
diff --git a/usr/share/locale/pt_BR/LC_MESSAGES/bigcam.mo b/usr/share/locale/pt_BR/LC_MESSAGES/bigcam.mo
index bb0732c..0eb6af0 100644
Binary files a/usr/share/locale/pt_BR/LC_MESSAGES/bigcam.mo and b/usr/share/locale/pt_BR/LC_MESSAGES/bigcam.mo differ
diff --git a/usr/share/polkit-1/actions/br.com.biglinux.bigcam.policy b/usr/share/polkit-1/actions/br.com.biglinux.bigcam.policy
new file mode 100644
index 0000000..7ebc847
--- /dev/null
+++ b/usr/share/polkit-1/actions/br.com.biglinux.bigcam.policy
@@ -0,0 +1,20 @@
+
+
+
+ BigLinux
+ https://www.biglinux.com.br
+
+ Manage BigCam-owned virtual cameras
+ Gerenciar câmeras virtuais pertencentes ao BigCam
+ Authentication is required to manage a BigCam virtual camera
+ É necessário autenticar para gerenciar uma câmera virtual do BigCam
+ camera-video-symbolic
+
+ no
+ no
+ auth_admin_keep
+
+ /usr/lib/bigcam/virtual-camera-helper
+
+