diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 8a33a37..53178bc 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,66 +1,49 @@ -name: End-to-End GTK Tests - +name: GTK and media integration on: - push: - branches: [ "main" ] pull_request: - branches: [ "main" ] - + branches: [main] + push: + branches: [main] +permissions: + contents: read +concurrency: + group: gtk-${{ github.ref }} + cancel-in-progress: true jobs: - build: - - runs-on: ubuntu-latest - + integration: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + container: + image: manjarolinux/base:latest + options: --shm-size=256m + env: + PYTHONDONTWRITEBYTECODE: '1' steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.11' - - - name: Install System Dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - python3-pip \ - xvfb \ - python3-dogtail \ - python3-gi \ - python3-gi-cairo \ - gir1.2-gtk-4.0 \ - gir1.2-gstreamer-1.0 \ - gstreamer1.0-plugins-base \ - gstreamer1.0-plugins-good \ - gstreamer1.0-plugins-bad \ - v4l2loopback-utils \ - ffmpeg \ - xauth \ - at-spi2-core - - - name: Setup at-spi for Headless Testing - run: | - # Required to allow dogtail to run under xvfb - mkdir -p ~/.config/at-spi2 - echo -e "[core]\nEnabled=true" > ~/.config/at-spi2/at-spi2-core.conf - - - name: Install Python Dependencies - run: | - python -m pip install --upgrade pip - pip install pytest aiohttp websockets opencv-python Pillow numpy qrcode pyzbar - - - name: Run E2E Dogtail UI Test (XVFB) - run: | - # Inicia Xvfb com a11y (AT-SPI) dbus-run-session - export DISPLAY=:99 - Xvfb $DISPLAY -screen 0 1920x1080x24 & - sleep 3 - - # Inicia o barramento DBUS e Roda os testes - dbus-run-session -- bash -c "python3 tests/test_ui_dogtail.py" - - - name: Run Chaos Monkey Test - run: | - # O teste de chaos precisa do módulo v4l2loopback, que o kernel host do github actions geralmente não permite carregar nativamente sem config avançada. - # Mas validamos a importação sintática do runner de testes: - python3 -m py_compile tests/chaos_hotplug.py + - name: Install current Manjaro Python and matching GI runtime + run: | + pacman -Syu --noconfirm --needed git ca-certificates python python-pytest python-hypothesis python-cryptography python-gobject python-cairo python-numpy python-opencv python-pillow python-aiohttp python-qrcode python-atspi python-polib gtk4 libadwaita gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav gst-plugin-gtk4 ffmpeg openssl dbus xorg-server-xvfb xorg-xauth openbox at-spi2-core xdotool scrot pulseaudio adwaita-icon-theme librsvg ttf-dejavu xdg-user-dirs xdg-utils procps-ng psmisc util-linux gettext mesa + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - name: Pure regression tests and syntax + run: | + /usr/bin/python3 -m compileall -q usr/share/biglinux/bigcam tests + /usr/bin/python3 -m pytest -q tests/audit + bash -n tests/integration/session.sh + - name: Actual GUI, AT-SPI, capture, recording and error handling + shell: bash + run: | + set -euo pipefail + useradd --create-home --uid 10001 --user-group bigcam-test + mkdir -p /tmp/bigcam-results + chown bigcam-test:bigcam-test /tmp/bigcam-results + chmod -R a+rX "$GITHUB_WORKSPACE" + timeout --kill-after=10s 180s runuser -u bigcam-test -- env BIGCAM_TEST_RESULTS=/tmp/bigcam-results bash tests/integration/session.sh 2>&1 | tee /tmp/bigcam-results/session.log + - name: Preserve evidence even when a test fails + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: graphical-manjaro-${{ github.sha }} + path: /tmp/bigcam-results/ + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/translate-and-build-package.yml b/.github/workflows/translate-and-build-package.yml index d67f821..0532d84 100644 --- a/.github/workflows/translate-and-build-package.yml +++ b/.github/workflows/translate-and-build-package.yml @@ -1,83 +1,66 @@ -name: Translate and Build Package - +name: Catalog validation and verified build dispatch on: + pull_request: + branches: [main] + paths: ['locale/**', 'usr/share/biglinux/bigcam/**', '.github/workflows/translate-and-build-package.yml'] + workflow_run: + workflows: ['GTK and media integration'] + types: [completed] + branches: [main] workflow_dispatch: inputs: - debug_enabled: + dispatch_build: type: boolean - description: 'With TMATE' - required: false + description: Dispatch the reviewed main revision to BigLinux package builders + required: true default: false - - push: - branches: [ "*" ] - +permissions: + contents: read jobs: - build: - runs-on: ubuntu-latest + catalogs: + runs-on: ubuntu-24.04 + timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - name: If Template exit - shell: bash - run: | - cancel () { - echo "Pacote de template, não precisa traduzir e compilar" - echo "SAINDO...." - curl -s -H "Authorization: token ${{ github.token }}" \ - -X POST "https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel" - sleep infinity - } - if [ -n "$(grep biglinux-package-template pkgbuild/PKGBUILD)" ];then - cancel - fi - - - name: Install Translator Dependencies + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + ref: ${{ github.event.workflow_run.head_sha || github.sha }} + - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends gettext + - name: Validate catalogs without translation-service secrets or repository writes shell: bash run: | - sudo apt-get update - sudo apt-get install \ - git \ - gettext \ - npm - # Install attranslator - sudo npm install --location=global attranslate - - ## Tmate ## - - name: Setup TMATE Session - uses: mxschmitt/action-tmate@v3 - if: ${{ github.event_name == 'workflow_dispatch' && inputs.debug_enabled }} - - - name: Translate Package - shell: bash #-O extglob {0} + set -euo pipefail + mkdir -p "$RUNNER_TEMP/catalogs" + for po in locale/*.po; do + msgfmt --check --check-format -o "$RUNNER_TEMP/catalogs/$(basename "${po%.po}").mo" "$po" + done + dispatch: + needs: catalogs + if: >- + github.repository == 'biglinux/bigcam' && + ((github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.head_repository.full_name == github.repository) || + (github.event_name == 'workflow_dispatch' && inputs.dispatch_build && github.ref == 'refs/heads/main')) + runs-on: ubuntu-24.04 + timeout-minutes: 3 + steps: + - name: Dispatch tested main; never execute repository code with this credential env: - AZURE_KEY: ${{ secrets.AZURE_KEY }} - OPENAI_KEY: ${{ secrets.OPENAI_KEY }} - run: | - cd .. - git clone https://github.com/biglinux/big-auto-translator.git - mv big-auto-translator/gettext_po_generator_github.sh . - sudo cp -f big-auto-translator/openai-translate.js /usr/local/lib/node_modules/attranslate/dist/services/openai-translate.js - gitfolder=$(echo ${{ github.repository }} | rev | cut -d "/" -f1 | rev) - # export OriginalLang=pt-BR - bash gettext_po_generator_github.sh $gitfolder - - - name: Update github - run: | - git config --local user.email "github-actions[bot]@users.noreply.github.com" - git config --local user.name "github-actions[bot]" - git add --all - if [ -n "$(git commit -m "translate $(date +%y-%m-%d_%H:%M)" -a | grep "nothing to commit")" ];then exit 0; fi - - - name: Push changes - uses: ad-m/github-push-action@master - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.ref }} - - - name: Send Hooks BigLinux Build Package + WEBHOOK_TOKEN: ${{ secrets.WEBHOOK_TOKEN }} + REVISION: ${{ github.event.workflow_run.head_sha || github.sha }} shell: bash run: | - if [ -z "$(grep biglinux-package-template pkgbuild/PKGBUILD)" ];then - curl -X POST -H "Accept: application/json" -H "Authorization: token ${{ secrets.WEBHOOK_TOKEN }}" --data '{"event_type": "${{ github.repository }}", "client_payload": { "branch": "${{ github.ref_name }}", "url": "https://github.com/${{ github.repository }}"}}' https://api.github.com/repos/BigLinux-Package-Build/build-package/dispatches - curl -X POST -H "Accept: application/json" -H "Authorization: token ${{ secrets.WEBHOOK_TOKEN }}" --data '{"event_type": "${{ github.repository }}", "client_payload": { "branch": "${{ github.ref_name }}", "url": "https://github.com/${{ github.repository }}"}}' https://api.github.com/repos/BigLinux-Package-Build/build-package-ARM/dispatches + set -euo pipefail + test -n "$WEBHOOK_TOKEN" + current=$(git ls-remote https://github.com/biglinux/bigcam.git refs/heads/main | cut -f1) + if [ "$current" != "$REVISION" ]; then + echo 'main advanced; its own successful integration run will dispatch the build.' + exit 0 fi + body='{"event_type":"biglinux/bigcam","client_payload":{"branch":"main","url":"https://github.com/biglinux/bigcam"}}' + for repository in build-package build-package-ARM; do + curl --fail-with-body --silent --show-error --max-time 30 -X POST \ + -H 'Accept: application/vnd.github+json' -H "Authorization: Bearer $WEBHOOK_TOKEN" \ + -H 'X-GitHub-Api-Version: 2022-11-28' --data "$body" \ + "https://api.github.com/repos/BigLinux-Package-Build/$repository/dispatches" + done diff --git a/default.nix b/default.nix index 7520377..e419562 100644 --- a/default.nix +++ b/default.nix @@ -15,6 +15,10 @@ libcamera, zbar, polkit, + kmod, + linuxPackages, + openssl, + pulseaudio, }: let @@ -26,10 +30,11 @@ let opencv4 qrcode aiohttp + pillow ] ); in -stdenv.mkDerivation { +stdenv.mkDerivation rec { pname = "bigcam"; version = "4.5.0"; @@ -84,12 +89,25 @@ stdenv.mkDerivation { cp -r usr/share/locale/* $out/share/locale/ fi - # System config (polkit, modprobe, sudoers) + # Module configuration if [ -d etc ]; then mkdir -p $out/etc cp -r etc/* $out/etc/ fi + install -Dm755 usr/lib/bigcam/virtual-camera-helper $out/lib/bigcam/virtual-camera-helper + install -Dm644 usr/share/polkit-1/actions/br.com.biglinux.bigcam.policy \ + $out/share/polkit-1/actions/br.com.biglinux.bigcam.policy + substituteInPlace $out/share/biglinux/bigcam/core/virtual_camera.py \ + $out/share/polkit-1/actions/br.com.biglinux.bigcam.policy \ + --replace-fail /usr/lib/bigcam/virtual-camera-helper $out/lib/bigcam/virtual-camera-helper + substituteInPlace $out/lib/bigcam/virtual-camera-helper \ + --replace-fail '#!/usr/bin/python3 -I' '#!${python3}/bin/python3 -I' \ + --replace-fail 'SAFE_PATH = "/usr/sbin:/usr/bin:/sbin:/bin"' \ + 'SAFE_PATH = "${lib.makeBinPath [ kmod linuxPackages.v4l2loopback.bin ]}"' + substituteInPlace $out/share/biglinux/bigcam/core/phone_tls.py \ + --replace-fail '"PATH": "/usr/bin:/bin"' '"PATH": "${lib.makeBinPath [ openssl ]}"' + # Launcher script mkdir -p $out/bin cat > $out/bin/bigcam <<'LAUNCHER' @@ -110,7 +128,7 @@ stdenv.mkDerivation { postFixup = '' wrapProgram $out/bin/bigcam \ "''${gappsWrapperArgs[@]}" \ - --prefix PATH : "${lib.makeBinPath [ pythonEnv ffmpeg v4l-utils gphoto2 ]}" \ + --prefix PATH : "${lib.makeBinPath [ pythonEnv ffmpeg v4l-utils gphoto2 pipewire polkit openssl pulseaudio linuxPackages.v4l2loopback.bin ]}" \ --prefix PYTHONPATH : "$out/share/biglinux/bigcam" \ --prefix PYTHONPATH : "${pythonEnv}/${pythonEnv.sitePackages}" \ --prefix GI_TYPELIB_PATH : "${lib.makeSearchPath "lib/girepository-1.0" buildInputs}" \ diff --git a/etc/modprobe.d/v4l2loopback.conf b/etc/modprobe.d/v4l2loopback.conf index 01e7edb..a69d135 100644 --- a/etc/modprobe.d/v4l2loopback.conf +++ b/etc/modprobe.d/v4l2loopback.conf @@ -1,13 +1,2 @@ -# v4l2loopback configuration for BigCam -# Fallback config when v4l2loopback-ctl is unavailable. -# When v4l2loopback-ctl IS available, BigCam loads with devices=0 -# and creates devices dynamically via v4l2loopback-ctl add. -# -# Parameters: -# devices=5 - Create 5 virtual video devices (one per camera) -# exclusive_caps=1,... - Allow browsers/WebRTC apps to see ALL cameras -# max_buffers=8 - Larger buffer to absorb USB timing jitter -# video_nr=20-24 - Use /dev/video20-24 (avoids physical camera collisions) -# card_label - Friendly name shown in apps -# -options v4l2loopback devices=5 exclusive_caps=1,1,1,1,1 max_buffers=8 video_nr=20,21,22,23,24 card_label="BigCam Virtual 1,BigCam Virtual 2,BigCam Virtual 3,BigCam Virtual 4,BigCam Virtual 5" +# BigCam creates its own loopbacks dynamically after Polkit authorization. +# No global options: do not override configurations belonging to other applications. diff --git a/etc/polkit-1/actions/br.com.biglinux.bigcam.policy b/etc/polkit-1/actions/br.com.biglinux.bigcam.policy deleted file mode 100644 index 907c9ee..0000000 --- a/etc/polkit-1/actions/br.com.biglinux.bigcam.policy +++ /dev/null @@ -1,21 +0,0 @@ - - - - BigLinux - https://www.biglinux.com.br - - - Load or unload the v4l2loopback kernel module for BigCam - Authentication is required to enable the virtual camera - camera-video-symbolic - - auth_admin - auth_admin - auth_admin_keep - - /usr/share/biglinux/bigcam/script/load-v4l2loopback.sh - true - - diff --git a/etc/sudoers.d/bigcam b/etc/sudoers.d/bigcam index 56dfd03..175e1cf 100644 --- a/etc/sudoers.d/bigcam +++ b/etc/sudoers.d/bigcam @@ -1,11 +1,2 @@ -# Allow wheel group to load/unload v4l2loopback without password (BigCam) -# modprobe may live in /usr/bin or /usr/sbin depending on distro -%wheel ALL=(root) NOPASSWD: /usr/bin/modprobe v4l2loopback * -%wheel ALL=(root) NOPASSWD: /usr/bin/modprobe -r v4l2loopback -%wheel ALL=(root) NOPASSWD: /usr/sbin/modprobe v4l2loopback * -%wheel ALL=(root) NOPASSWD: /usr/sbin/modprobe -r v4l2loopback -# Allow dynamic v4l2loopback device management (add/delete) -%wheel ALL=(root) NOPASSWD: /usr/sbin/v4l2loopback-ctl add * -%wheel ALL=(root) NOPASSWD: /usr/sbin/v4l2loopback-ctl delete * -%wheel ALL=(root) NOPASSWD: /usr/bin/v4l2loopback-ctl add * -%wheel ALL=(root) NOPASSWD: /usr/bin/v4l2loopback-ctl delete * +# BigCam no longer grants passwordless access to generic system utilities. +# Virtual cameras are managed by the Polkit-authorized fixed-operation helper. diff --git a/etc/udev/rules.d/99-bigcam-webcam-power.rules b/etc/udev/rules.d/99-bigcam-webcam-power.rules deleted file mode 100644 index 1db94ea..0000000 --- a/etc/udev/rules.d/99-bigcam-webcam-power.rules +++ /dev/null @@ -1,3 +0,0 @@ -# BigCam: Disable USB autosuspend for video capture devices to prevent frame drops. -# When a V4L2 device appears, set the parent USB device power control to "on". -ACTION=="add", SUBSYSTEM=="video4linux", RUN+="/bin/sh -c 'echo on > /sys/class/video4linux/%k/device/../power/control 2>/dev/null || true'" diff --git a/locale/bigcam.pot b/locale/bigcam.pot index e373128..789eade 100644 --- a/locale/bigcam.pot +++ b/locale/bigcam.pot @@ -4,3117 +4,2682 @@ # FIRST AUTHOR , YEAR. # #, fuzzy -msgid "" -msgstr "Project-Id-Version: bigcam\n" - "Report-Msgid-Bugs-To: \n" - "Last-Translator: FULL NAME \n" - "Language-Team: LANGUAGE \n" - "Language: \n" - "MIME-Version: 1.0\n" - "Content-Type: text/plain; charset=UTF-8\n" - "Content-Transfer-Encoding: 8bit\n" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 81 -msgid "High resource usage detected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 85 -#, python-format -msgid "The application is using %s and %s." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 87 -msgid "Active features that may be causing this:" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 95 -msgid "You can optimize by disabling the heaviest features, or continue if " - "you understand the impact." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 100 -msgid "No features can be disabled (active camera sources cannot be stopped " - "from here)." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 108 -msgid "Don't show this warning again" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 112 -msgid "Suppress resource usage warnings permanently" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 117 -msgid "I understand, continue" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/resource_warning_dialog.py, line: 119 -msgid "Optimize (disable heavy features)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 100 -msgid "General" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 102 -msgid "Reset general settings" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 107 -msgid "Photo directory" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 113 -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 90 -msgid "Open photos folder" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 124 -msgid "Video directory" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 130 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 109 -msgid "Open videos folder" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 140 -msgid "Theme" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 143 -msgid "Light" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 143 -msgid "Dark" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 151 -msgid "Application theme" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 157 -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 163 -msgid "USB hotplug detection" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 158 -msgid "Automatically detect cameras when plugged or unplugged." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 170 -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 178 -msgid "Show help on hover" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 171 -msgid "Show tooltip hints when hovering over buttons." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 185 -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 193 -msgid "Resource usage monitor" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 186 -msgid "Warn when CPU or memory usage is high." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 201 -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 209 -msgid "Auto-optimize resources" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 202 -msgid "Automatically disable heavy background features when usage is high." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 216 -msgid "Reset resource warnings" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 217 -msgid "Re-enable all dismissed resource usage warnings." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 222 -msgid "Reset" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 225 -msgid "Reset dismissed resource warnings" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 235 -msgid "Preview" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 237 -msgid "Reset preview settings" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 241 -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 247 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 413 -msgid "Mirror preview" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 242 -msgid "Flip the preview horizontally like a mirror." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 253 -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 258 -msgid "Show FPS counter" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 264 -msgid "Grid overlay" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 265 -msgid "Show a rule-of-thirds grid over the preview." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 274 -msgid "Background transparency" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 275 -msgid "Controls the window background transparency." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 291 -msgid "Overlay opacity" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 292 -msgid "Controls bar background darkness." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 308 -msgid "Controls opacity" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 309 -msgid "Transparency of the buttons over the preview." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 324 -msgid "Direct V4L2 access" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 325 -msgid "Bypass PipeWire and access the camera directly. May fix flickering " - "on some webcams." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 335 -msgid "Camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 337 -msgid "Reset camera settings" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 341 -msgid "Resolution" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 344 -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 367 -msgid "Auto" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 364 -msgid "FPS limit" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 382 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 561 -msgid "Capture timer" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 383 -msgid "Countdown before taking a photo." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 387 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 554 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 894 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1017 -msgid "Off" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 403 -msgid "Recording" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 405 -msgid "Reset recording settings" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 411 -msgid "Video Codec" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 430 -msgid "Audio Codec" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 449 -msgid "Container" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 487 -msgid "Video Bitrate (kbps)" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 511 -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 79 -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 93 -msgid "QR Code Scanner" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 513 -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 97 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 428 -msgid "Scan QR Codes" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 514 -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 98 -msgid "Detect QR codes in the camera feed" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 522 -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 87 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 40 -msgid "Virtual Camera" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 524 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 43 -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 27 -msgid "Status" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 530 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 236 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 360 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 59 -msgid "Device" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 531 -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 948 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 60 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 160 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 161 -msgid "Not loaded" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 537 -msgid "Enable virtual camera service" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 538 -msgid "Master switch to allow virtual camera outputs." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 548 -msgid "Virtual Cameras" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 549 -msgid "Select which cameras should output to a virtual device." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 567 -msgid "Maximum virtual cameras" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 568 -msgid "How many virtual camera devices to create (one per camera)." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 577 -msgid "Device name" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 582 -msgid "Name template for virtual cameras. Devices will be named ' 1', " - "' 2', etc.\n" - "Press Enter or click ✓ to apply." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 734 -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 618 -# File: bigcam/usr/share/biglinux/bigcam/ui/effects_page.py, line: 106 -msgid "Reset to defaults" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 929 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 133 -msgid "Reboot required (kernel updated)" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 931 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 135 -msgid "v4l2loopback not available" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 936 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 146 -msgid "Active" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 941 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 152 -msgid "Module loaded" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 946 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 158 -msgid "Module not loaded" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/settings_page.py, line: 965 -msgid "No cameras connected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 56 -msgid "Captured Photos" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 63 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 82 -msgid "Grid view" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 66 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 85 -msgid "List view" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 78 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 97 -msgid "Select items" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 84 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 245 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 380 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 103 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1092 -msgid "Refresh" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 136 -msgid "No photos yet" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 137 -msgid "Captured photos will appear here." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 146 -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 180 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 165 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 199 -msgid "0 selected" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 149 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 168 -msgid "Select All" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 153 -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 292 -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 354 -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 387 -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 423 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 172 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 346 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 395 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 429 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 524 -msgid "Delete" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 180 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 199 -#, python-format -msgid "%d selected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 383 -#, python-format -msgid "Delete %d photos?" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 384 -msgid "These photos will be permanently deleted." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 386 -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 422 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 428 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 523 -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 431 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2439 -msgid "Cancel" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 419 -msgid "Delete photo?" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/photo_gallery.py, line: 420 -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 521 -#, python-format -msgid "\"%s\" will be permanently deleted." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 84 -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 399 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 142 -msgid "Phone" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 93 -msgid "SMS" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 106 -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 135 -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 400 -msgid "E-mail" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 155 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 122 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 979 -msgid "Wi-Fi" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 165 -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 413 -msgid "Location" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 179 -msgid "Calendar Event" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 211 -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 231 -msgid "Contact Card" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 236 -msgid "Payment (PIX)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 263 -msgid "Authentication (TOTP)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 276 -msgid "App Store" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 283 -msgid "Social Network" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 287 -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 393 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 687 -# File: bigcam/usr/share/biglinux/bigcam/ui/ip_camera_dialog.py, line: 50 -msgid "URL" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 292 -msgid "Barcode" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 295 -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 394 -msgid "Text" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 327 -msgid "QR Code Detected" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 362 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 80 -msgid "Actions" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 395 -msgid "Number" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 396 -msgid "Message" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 397 -msgid "Address" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 398 -# File: bigcam/usr/share/biglinux/bigcam/ui/ip_camera_dialog.py, line: 44 -msgid "Name" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 401 -msgid "Organization" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 402 -msgid "Title" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 403 -msgid "Network (SSID)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 404 -msgid "Password" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 405 -msgid "Security" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 406 -msgid "Hidden" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 407 -msgid "Latitude" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 408 -msgid "Longitude" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 409 -msgid "Coordinates" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 410 -msgid "Summary" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 411 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 313 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 517 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 621 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 733 -msgid "Start" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 412 -msgid "End" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 414 -msgid "Payload" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 415 -msgid "Currency" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 416 -msgid "Account" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 417 -msgid "Secret" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 418 -msgid "Issuer" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 419 -msgid "URI" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 420 -msgid "Subject" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 421 -msgid "Code" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 432 -msgid "Copy" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 444 -msgid "Open in Browser" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 453 -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 461 -msgid "Copy Number" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 468 -msgid "Copy Message" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 472 -msgid "WhatsApp" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 477 -msgid "Open E-mail Client" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 487 -msgid "Copy Password" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 495 -msgid "Copy SSID" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 503 -msgid "Open in Maps" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 512 -msgid "Save .ics File" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 521 -msgid "Save Contact" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 530 -msgid "Copy PIX" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 539 -msgid "Copy Address" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 548 -msgid "Copy Secret" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 555 -msgid "Copy URI" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 563 -msgid "Copy Code" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 571 -msgid "Copy Raw" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 574 -msgid "Save to File" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 607 -msgid "Copied!" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 643 -msgid "iCalendar Files" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 669 -msgid "vCard Files" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 674 -msgid "Text Files" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 689 -msgid "All Files" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 705 -#, python-format -msgid "Saved: %s" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/qr_dialog.py, line: 709 -#, python-format -msgid "Error: %s" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 58 -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 92 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 151 -msgid "Phone as Webcam" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 114 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 934 -msgid "Browser" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 130 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1011 -msgid "USB" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 138 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1041 -msgid "AirPlay" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 183 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1280 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1326 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1341 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1898 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1973 -msgid "Idle" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 237 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 361 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1593 -msgid "Searching…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 257 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 477 -msgid "Lens" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 259 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 479 -msgid "Rear camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 259 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 479 -msgid "Front camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 266 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 483 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 585 -msgid "Quality" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 268 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 485 -msgid "Maximum" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 280 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 470 -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 28 -# File: bigcam/usr/share/biglinux/bigcam/ui/effects_page.py, line: 25 -msgid "Advanced" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 287 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 490 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 595 -msgid "FPS" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 291 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 494 -msgid "Bitrate" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 314 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 518 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 622 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 734 -msgid "Stop" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 370 -msgid "Switch device to Wi-Fi mode" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 391 -msgid "Find wireless devices" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 392 -msgid "Detect paired devices on your network" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 400 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1545 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1579 -msgid "Scan" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 415 -msgid "Pair new device" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 416 -msgid "Only needed once per device" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 425 -msgid "6-digit code (shown on phone screen)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 431 -msgid "IP:Port (shown on phone screen)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 437 -msgid "Pair" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 448 -msgid "Auto-fill IP:Port" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 450 -msgid "Detects the address automatically. Requires 'Pair with code' (not " - "QR) open on the phone." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 455 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1601 -msgid "Find" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 568 -msgid "AirPlay mirrors the iPhone screen. Open the Camera app on your " - "iPhone after connecting to use it as a webcam." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 574 -msgid "Visible name" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 602 -msgid "Rotation" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 605 -msgid "None" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 606 -msgid "90° Right" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 607 -msgid "90° Left" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 608 -msgid "180°" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 663 -msgid "Open this URL in any phone browser to stream the camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 676 -msgid "QR Code — scan with your phone" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 688 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1282 -msgid "Start to see the address" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 695 -msgid "Copy URL" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 701 -msgid "Copy URL to clipboard" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 712 -msgid "Port" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 786 -msgid "Instructions" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 817 -#, python-format -msgid "Install required: %s" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 838 -msgid "How to connect your phone" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 919 -msgid "Browser (Android / iPhone)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 920 -msgid "Works with any phone, no app required." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 924 -msgid "Connect both devices to the same Wi-Fi network" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 925 -msgid "Click 'Start' on the Browser tab" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 926 -msgid "Scan the QR code with your phone or type the URL" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 927 -msgid "Accept the security warning in the browser" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 928 -msgid "Tap 'Start' on the phone's browser page" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 950 -msgid "Wi-Fi (Android 11+)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 951 -msgid "No cable needed after the first setup." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 954 -msgid "Quick method" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 956 -msgid "If connected via USB, use the wireless icon on the device selector " - "to switch to Wi-Fi instantly." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 962 -msgid "First-time pairing (without USB)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 964 -msgid "Enable Developer Options (same steps as USB above)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 965 -msgid "Go to Settings → Developer Options → Wireless Debugging" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 966 -msgid "Tap 'Pair device with pairing code'" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 967 -msgid "Use 'pairing CODE', NOT 'QR Code'" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 968 -msgid "Note the IP:Port and 6-digit code shown on the phone" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 969 -msgid "On the Wi-Fi tab, expand 'Pair new device'" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 970 -msgid "Type the IP:Port and code, then tap 'Pair'" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 971 -msgid "Or tap 'Find' to auto-fill the IP:Port" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 972 -msgid "After pairing, tap 'Scan' to find the device" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 973 -msgid "Click 'Start' on the Wi-Fi tab" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 995 -msgid "USB (Android)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 996 -msgid "The easiest and fastest method." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1000 -msgid "On your Android phone, go to Settings → About Phone" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1001 -msgid "Tap 'Build Number' 7 times to unlock Developer Options" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1002 -msgid "Go to Settings → Developer Options → enable 'USB Debugging'" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1003 -msgid "Connect the USB cable to the computer" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1004 -msgid "Accept the USB Debugging prompt on the phone screen" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1005 -msgid "Click 'Start' on the USB tab" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1027 -msgid "AirPlay (iPhone / iPad)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1028 -msgid "Mirrors the entire screen (not just the camera)." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1032 -msgid "Make sure both devices are on the same Wi-Fi network" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1033 -msgid "Click 'Start' on the AirPlay tab" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1034 -msgid "On your iPhone, open Control Center (swipe down from top-right)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1035 -msgid "Tap 'Screen Mirroring' and select 'BigCam'" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1191 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1316 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1338 -msgid "Connected via browser" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1196 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1245 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1323 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1335 -msgid "Waiting for connection…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1202 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1208 -msgid "Connected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1214 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1983 -msgid "AirPlay connected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1227 -msgid "Starting server…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1242 -#, python-format -msgid "Server listening on port %d" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1260 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1263 -msgid "Failed to start server" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1411 -msgid "No USB device connected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1415 -msgid "No device found. Connect via USB cable and enable USB Debugging, " - "then tap 'Refresh'." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1424 -msgid "Selected device is no longer available. Tap 'Refresh' to update." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1454 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1823 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1915 -msgid "No v4l2loopback device" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1461 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1830 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1889 -msgid "Starting…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1533 -msgid "Scanning…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1535 -msgid "Scanning network…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1547 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1768 -msgid "No devices found" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1549 -msgid "No devices found. Try 'Pair new device'." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1555 -#, python-format -msgid "Found: %s" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1557 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1666 -#, python-format -msgid "Connecting to %s…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1582 -#, python-format -msgid "Connected to %s" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1583 -msgid "Ready to start camera" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1587 -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 654 -msgid "Connection failed" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1606 -#, python-format -msgid "Found %s — enter the 6-digit code below" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 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 "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1626 -msgid "Enter IP:Port and pairing code" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1631 -msgid "Pairing…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1653 -msgid "Scanning for paired device…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1662 -msgid "No devices found after pairing" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1689 -#, python-format -msgid "Connected to %s — starting camera…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1699 -msgid "Connection failed after pairing" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1716 -msgid "No device available to start" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1734 -msgid "Switching to Wi-Fi…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1745 -#, python-format -msgid "Wi-Fi: %s (unplug USB)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1750 -msgid "Wi-Fi switch failed" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1784 -msgid "No device found. Pair a device first, then tap 'Scan'." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1793 -msgid "Selected device is no longer available. Tap 'Scan' to refresh." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1861 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1893 -msgid "Connected via USB" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1863 -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1895 -msgid "Connected via Wi-Fi" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1871 -msgid "Disconnected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1901 -msgid "Error" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1944 -msgid "Starting AirPlay…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1960 -msgid "Failed to start AirPlay. Try again." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 1997 -msgid "AirPlay stopped unexpectedly" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/phone_camera_dialog.py, line: 2005 -msgid "Waiting for AirPlay connection…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 47 -msgid "Welcome to BigCam" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 55 -msgid "Your universal webcam control center for Linux" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 68 -msgid "Photo & Video Capture" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 69 -msgid "Take photos and record videos\n" - "with timer and countdown support" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 71 -msgid "Multiple Cameras" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 72 -msgid "Switch between USB, IP, and\n" - "virtual cameras with hotplug support" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 76 -msgid "Mirror Preview" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 77 -msgid "Flip the camera preview\n" - "horizontally like a mirror" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 80 -msgid "Scan QR codes and barcodes\n" - "directly from the camera feed" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 84 -msgid "Real-Time Effects" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 85 -msgid "Apply brightness, contrast, blur,\n" - "sepia, vignette and more effects live" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 88 -msgid "Create a virtual camera device\n" - "for use in video calls" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 93 -msgid "Use your smartphone camera\n" - "wirelessly as a webcam" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 95 -msgid "Advanced Controls" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 96 -msgid "Fine-tune exposure, white balance\n" - "and save per-camera profiles" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 122 -msgid "Tip: Press Ctrl+P to capture, Ctrl+R to record, Tab to toggle sidebar" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 143 -msgid "Show dialog on startup" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/welcome_dialog.py, line: 154 -msgid "Let's Start" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 53 -msgid "Virtual camera status" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 68 -msgid "Module" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 69 -msgid "v4l2loopback" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 83 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 90 -msgid "Enable virtual camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 84 -msgid "Create a virtual camera output for video calls and streaming." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 99 -msgid "Usage" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 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 "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 131 -msgid "Module not available for current kernel" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 137 -msgid "Not installed" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 138 -msgid "—" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 149 -# File: bigcam/usr/share/biglinux/bigcam/ui/virtual_camera_page.py, line: 155 -msgid "Loaded" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 76 -# File: bigcam/usr/share/biglinux/bigcam/ui/effects_page.py, line: 68 -msgid "OpenCV not available" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 77 -msgid "Install python-opencv to use tools." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 104 -msgid "Smile Capture" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 108 -msgid "Capture on Smile" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 109 -msgid "Automatically take a photo when a smile is detected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 115 -msgid "Sensitivity" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 324 -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 408 -msgid "Watching for smiles..." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 384 -msgid "Smile detected! Capturing..." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 396 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1769 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1842 -msgid "Photo saved!" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/tools_page.py, line: 399 -msgid "Capture failed." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/about_dialog.py, line: 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 "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/ip_camera_dialog.py, line: 24 -msgid "Add IP Camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/ip_camera_dialog.py, line: 42 -msgid "Camera Details" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/ip_camera_dialog.py, line: 46 -msgid "Camera name" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/ip_camera_dialog.py, line: 53 -msgid "Camera URL (RTSP or HTTP)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/ip_camera_dialog.py, line: 60 -msgid "Add Camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/ip_camera_dialog.py, line: 64 -msgid "Add IP camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 75 -msgid "Recorded Videos" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 155 -msgid "No videos yet" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 156 -msgid "Recorded videos will appear here." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 425 -#, python-format -msgid "Delete %d videos?" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 426 -msgid "These videos will be permanently deleted." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/video_gallery.py, line: 520 -msgid "Delete video?" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 22 -msgid "Image" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 23 -msgid "Exposure" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 24 -msgid "Focus" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 25 -msgid "White Balance" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 26 -msgid "Capture" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 91 -msgid "No camera selected" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 92 -msgid "Select a camera to see its controls." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 125 -msgid "Loading controls…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 164 -msgid "Phone camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 166 -msgid "Adjust resolution, quality, and FPS directly on the phone's browser " - "page. Use the Effects tab for brightness, contrast, and other " - "adjustments." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 173 -msgid "No adjustable controls" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 174 -msgid "This camera does not expose any controls." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 326 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1087 -msgid "Profiles" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 329 -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 330 -msgid "Profile" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 340 -msgid "Manage" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 341 -msgid "Manage profiles" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 346 -msgid "Save current settings as new profile" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 349 -msgid "Save profile" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 355 -msgid "Delete selected profile" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 358 -msgid "Delete profile" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 365 -msgid "Reset all controls to hardware defaults" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 369 -msgid "Hardware defaults" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 427 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1085 -msgid "Save Profile" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 428 -msgid "Enter a name for this profile:" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 432 -msgid "Save" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 437 -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 440 -msgid "Profile name" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_controls_page.py, line: 621 -#, python-format -msgid "Reset %s controls" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_selector.py, line: 64 -msgid "Select camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/camera_selector.py, line: 66 -msgid "Camera selector" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 81 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1949 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2300 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2352 -msgid "No camera" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 82 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1950 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2301 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2353 -msgid "Connect a camera or select one from the list above." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 89 -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 95 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1737 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2027 -msgid "Try again" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 223 -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 225 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 473 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 475 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 686 -msgid "Capture photo" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 247 -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 682 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 500 -msgid "Record video (Ctrl+R)" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 249 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 502 -msgid "Record video" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 295 -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 297 -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 459 -msgid "Mute" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 456 -msgid "Unmute" -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 579 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1330 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1429 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1786 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1852 -msgid "Please wait…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 647 -msgid "Camera in use by:" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 648 -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 651 -msgid "Camera busy" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 650 -msgid "Camera is being used" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 657 -msgid "Could not connect to the camera. Check the connection and try again." -msgstr "" - -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 676 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2201 -msgid "Stop recording" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 725 -# File: bigcam/usr/share/biglinux/bigcam/ui/preview_area.py, line: 736 -#, python-brace-format -msgid "{n} seconds remaining" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/effects_page.py, line: 22 -msgid "Adjustments" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/effects_page.py, line: 23 -msgid "Filters" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/effects_page.py, line: 24 -msgid "Artistic" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/effects_page.py, line: 69 -msgid "Install python-opencv to enable video effects." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/effects_page.py, line: 110 -#, python-format -msgid "Reset %s effects" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/notification.py, line: 59 -msgid "Dismiss" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/notification.py, line: 62 -msgid "Dismiss notification" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 149 -msgid "Use your phone as a webcam" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 168 -msgid "Phone camera status" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 249 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 646 -msgid "Controls" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 259 -msgid "Always on Top" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 304 -msgid "Refresh cameras" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 306 -msgid "Refresh camera list" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 316 -msgid "Menu" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 318 -msgid "Main menu" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 362 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 364 -msgid "Photo mode" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 371 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 373 -msgid "Video mode" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 400 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 716 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 722 -msgid "Last photo" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 402 -msgid "Open last photo" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 454 -msgid "Enable Virtual Camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 521 -msgid "Zoom level" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 523 -msgid "Change zoom level" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 536 -msgid "Toggle grid overlay" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 576 -msgid "Fullscreen (F11)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 647 -msgid "Effects" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 648 -msgid "Photos" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 649 -msgid "Videos" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 650 -msgid "Settings" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 680 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2166 -msgid "Start recording" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 714 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 719 -msgid "Last video" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 896 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1019 -msgid "Capture timer: Off" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 900 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1023 +msgid "" +msgstr "" +"Project-Id-Version: bigcam\n" +"Report-Msgid-Bugs-To: \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" +"Language: \n" +"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/ui/preview_area.py:377 #, python-format -msgid "Capture timer: %ds" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1080 -msgid "Capture Photo" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1081 -msgid "Record Video" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1086 -msgid "Load Profile" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1090 -msgid "Add IP Camera…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1091 -msgid "Phone as Webcam…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1093 -msgid "Welcome Screen" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1094 -msgid "About" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1098 -msgid "Quit" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1331 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1430 -msgid "Starting camera stream…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1368 -msgid "Camera streaming started!" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1388 -msgid "Capture not supported" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1390 -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 "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1397 -msgid "Camera does not support USB streaming." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1401 -msgid "Streaming failed" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1403 -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 "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1412 -msgid "Camera PTP streaming failed." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1416 -msgid "Failed to start camera streaming." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1459 -#, python-brace-format -msgid "Virtual Camera: {vcam_device} Created!" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1678 -msgid "QR Code detected!" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1684 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2177 -msgid "No camera selected." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1692 -msgid "Choose capture mode" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1694 -msgid "You can take a screenshot from the current preview or capture a full-" - "resolution photo directly from the camera." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1698 -msgid "Preview screenshot" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1699 -msgid "Camera photo (full resolution)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1727 -msgid "Camera in video mode" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1729 -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 "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1736 -msgid "Capture preview frame" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1757 -msgid "Capturing photo…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1774 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1847 -msgid "Failed to capture photo." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1787 -msgid "Switching to photography mode." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1853 -msgid "Resuming camera streaming…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1884 -msgid "Virtual Camera Limit Reached" -msgstr "" +msgid "%(source)s — audio %(index)d" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1885 -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 "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1887 -msgid "OK" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 1960 -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2315 -msgid "Show" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2016 +#: usr/share/biglinux/bigcam/ui/media_gallery.py:271 #, 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 "" +msgid "%d file could not be moved to Trash." +msgid_plural "%d files could not be moved to Trash." +msgstr[0] "" +msgstr[1] "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2021 +#: usr/share/biglinux/bigcam/ui/media_gallery.py:227 #, python-format -msgid "The camera \"%s\" is being used by another application.\n" - "\n" - "Close the other application to free the camera." -msgstr "" +msgid "%d item selected" +msgid_plural "%d items selected" +msgstr[0] "" +msgstr[1] "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2025 -msgid "Camera in use" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2036 +#: usr/share/biglinux/bigcam/ui/preview_area.py:735 #, python-format -msgid "Force close %s" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2042 -msgid "Close" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2139 -msgid "Virtual Camera enabled. Other applications can use /dev/video10." -msgstr "" +msgid "%d second remaining" +msgid_plural "%d seconds remaining" +msgstr[0] "" +msgstr[1] "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2169 +#: usr/share/biglinux/bigcam/ui/window.py:870 #, python-format -msgid "Video saved: %s" -msgstr "" +msgid "%d virtual camera allocated" +msgid_plural "%d virtual cameras allocated" +msgstr[0] "" +msgstr[1] "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2203 -msgid "Recording…" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2207 -msgid "Failed to start recording." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2244 -msgid "Profile saved." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2254 -msgid "No profiles found." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2266 +#: usr/share/biglinux/bigcam/ui/preview_area.py:408 #, python-format -msgid "Profile loaded: %s" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2407 -msgid "Phone (Browser Wi-Fi)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2411 -msgid "Phone (USB/Wi-Fi scrcpy)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2415 -msgid "Phone (AirPlay)" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2425 -msgid "Active cameras:" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2427 -msgid "Virtual Camera is enabled (other apps may depend on it)." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2430 -msgid "If you choose to keep it running, the camera will remain on after " - "closing the application." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2436 -msgid "Camera is active" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2437 -msgid "Stop camera and close" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2438 -msgid "Keep camera on" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2535 -msgid "High resource usage detected. Optimized automatically." -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2594 -msgid "Video effects" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2595 -msgid "Real-time filters, background blur, artistic effects" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2603 -msgid "Virtual camera" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2604 -msgid "v4l2loopback output for video conferencing" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2613 -msgid "Background virtual cameras" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2614 -msgid "Virtual camera feeds for inactive cameras" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2622 -msgid "Video recording" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2623 -msgid "Active video recording with encoding" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2635 -msgid "Phone camera server" -msgstr "" - -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2636 -msgid "HTTPS/WebSocket server for phone camera" -msgstr "" +msgid "%s — volume" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:608 +msgid "180°" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:49 +msgid "3A Lock" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:425 +msgid "6-digit code (shown on phone screen)" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:607 +msgid "90° Left" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:606 +msgid "90° Right" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1053 +msgid "About" +msgstr "" + +#: 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/ui/phone_camera_dialog.py:927 +msgid "Accept the security warning in the browser" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:416 +msgid "Account" +msgstr "" + +#: 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/ui/settings_page.py:980 +#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:146 +msgid "Active" +msgstr "" + +#: 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:47 +msgid "Auto ISO" +msgstr "" + +#: 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/ui/phone_camera_dialog.py:448 +msgid "Auto-fill IP:Port" +msgstr "" + +#: 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/ui/settings_page.py:161 +msgid "Automatically detect cameras when plugged or unplugged." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:205 +msgid "Automatically disable heavy background features when usage is high." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:237 +msgid "Automatically hide camera controls" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/tools_page.py:109 +msgid "Automatically take a photo when a smile is detected" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:236 +msgid "Avoid capture flashes and decorative animations." +msgstr "" + +#: 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/ui/window.py:2601 +msgid "Background virtual cameras" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:292 +msgid "Barcode" +msgstr "" + +#: 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: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/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/ui/qr_dialog.py:179 +msgid "Calendar Event" +msgstr "" + +#: 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/ui/ip_camera_dialog.py:42 +msgid "Camera Details" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1387 +msgid "Camera PTP streaming failed." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:53 +msgid "Camera URL (RTSP or HTTP)" +msgstr "" + +#: 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/camera_manager.py:144 +msgid "Camera detection failed. Try refreshing the camera list." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1372 +msgid "Camera does not support USB streaming." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1964 +msgid "Camera in use" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/preview_area.py:650 +msgid "Camera in use by:" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1721 +msgid "Camera in video mode" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2399 +msgid "Camera is active" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/preview_area.py:653 +msgid "Camera is being used" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/ip_camera_dialog.py:46 +msgid "Camera name" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1690 +msgid "Camera photo (full resolution)" +msgstr "" + +#: 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/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 "Capture timer: %ds" +msgstr "" + +#: 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/ui/media_gallery.py:33 +msgid "Captured Photos" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1755 +msgid "Capturing photo…" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:541 +msgid "Change zoom level" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:153 +msgid "Check the connection and try again." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1683 +msgid "Choose capture mode" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1033 +msgid "Click 'Start' on the AirPlay tab" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:925 +msgid "Click 'Start' on the Browser tab" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1005 +msgid "Click 'Start' on the USB tab" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:973 +msgid "Click 'Start' on the Wi-Fi tab" +msgstr "" + +#: usr/share/biglinux/bigcam/core/effects.py:276 +msgid "Clip Limit" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1981 +msgid "Close" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/controllers/sidebar_ctrl.py:63 +msgid "Close sidebar" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:421 +msgid "Code" +msgstr "" + +#: usr/share/biglinux/bigcam/core/effects.py:360 +msgid "Color Map" +msgstr "" + +#: 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/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 "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2645 -msgid "Scrcpy (Android camera)" -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/phone_camera_dialog.py:1699 +msgid "Connection failed after pairing" +msgstr "" + +#: usr/share/biglinux/bigcam/core/phone_strings.py:15 +msgid "" +"Connection failed. Check permissions, the address and whether another device " +"is connected." +msgstr "" + +#: 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/settings_page.py:478 +msgid "Container" +msgstr "" + +#: 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/window.py:266 +#: usr/share/biglinux/bigcam/ui/window.py:664 +msgid "Controls" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:303 +msgid "Controls bar background darkness." +msgstr "" + +#: 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/settings_page.py:237 +msgid "Controls remain visible while using keyboard focus." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:285 +msgid "Controls the window background transparency." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:409 +msgid "Coordinates" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:607 +msgid "Copied!" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:432 +msgid "Copy" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:539 +msgid "Copy Address" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:563 +msgid "Copy Code" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:468 +msgid "Copy Message" +msgstr "" + +#: 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/qr_dialog.py:530 +msgid "Copy PIX" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:487 +msgid "Copy Password" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:571 +msgid "Copy Raw" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:495 +msgid "Copy SSID" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:548 +msgid "Copy Secret" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:555 +msgid "Copy URI" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:695 +msgid "Copy URL" +msgstr "" + +#: 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 "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2646 -msgid "Android camera via USB/Wi-Fi ADB" -msgstr "" +#: usr/share/biglinux/bigcam/ui/media_gallery.py:272 +msgid "Could not move files to Trash." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2655 -msgid "AirPlay receiver" -msgstr "" +#: usr/share/biglinux/bigcam/ui/media_gallery.py:240 +msgid "Could not open the file or folder." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/window.py, line: 2656 -msgid "Apple AirPlay screen mirroring via UxPlay" -msgstr "" +#: usr/share/biglinux/bigcam/ui/media_gallery.py:114 +msgid "Could not read the media folder." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/ui/controllers/mobile_device_ctrl.py, line: 81 -msgid "Phone Mic (Browser)" -msgstr "" +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:594 +msgid "Could not reset camera controls." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/phone_camera.py, line: 731 -msgid "python-aiohttp is not installed" -msgstr "" +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:444 +msgid "Could not save the profile. Check its name and folder permissions." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/phone_camera.py, line: 750 -msgid "Server did not start in time" -msgstr "" +#: usr/share/biglinux/bigcam/core/phone_camera.py:166 +#, python-format +msgid "Could not start the camera server: %s" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:396 +msgid "Countdown before taking a photo." +msgstr "" + +#: 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/virtual_camera_page.py:84 +msgid "Create a virtual camera output for video calls and streaming." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:415 +msgid "Currency" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:146 +msgid "Dark" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:360 +msgid "Delete profile" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:357 +msgid "Delete selected profile" +msgstr "" + +#: usr/share/biglinux/bigcam/core/effects.py:307 +msgid "Denoise" +msgstr "" + +#: 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/phone_camera_dialog.py:392 +msgid "Detect paired devices on your network" +msgstr "" + +#: 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: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/settings_page.py:607 +msgid "Device name" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:337 +msgid "Direct V4L2 access" +msgstr "" + +#: 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/notification.py:59 +msgid "Dismiss" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/notification.py:62 +msgid "Dismiss notification" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:108 +msgid "Don't show this warning again" +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" +msgstr "" + +#: usr/share/biglinux/bigcam/core/effects.py:347 +msgid "Edge Detection" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:665 +msgid "Effects" +msgstr "" + +#: 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/window.py:472 +msgid "Enable Virtual Camera" +msgstr "" + +#: 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/settings_page.py:568 +msgid "Enable virtual camera service" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:412 +msgid "End" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1626 +msgid "Enter IP:Port and pairing code" +msgstr "" + +#: 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:1901 +msgid "Error" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:709 +#, python-format +msgid "Error: %s" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:25 +msgid "Exposure" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:36 +msgid "Exposure Auto Priority" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:43 +msgid "Exposure Bias" +msgstr "" + +#: 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: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/settings_page.py:377 +msgid "FPS limit" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1773 +msgid "Failed to capture photo." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1803 +msgid "Failed to capture photo. Check the camera mode and connection." +msgstr "" + +#: 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:1960 +msgid "Failed to start AirPlay. Try again." +msgstr "" + +#: 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/core/stream_engine.py:605 +msgid "Failed to start camera streaming process." +msgstr "" + +#: 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/window.py:2121 +msgid "Failed to start recording." +msgstr "" + +#: 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/effects_page.py:23 +msgid "Filters" +msgstr "" + +#: 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:455 +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1601 +msgid "Find" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:391 +msgid "Find wireless devices" +msgstr "" + +#: 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/window.py:1216 +msgid "Finish the current capture before changing cameras." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:962 +msgid "First-time pairing (without USB)" +msgstr "" + +#: 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/settings_page.py:252 +msgid "Flip the preview horizontally like a mirror." +msgstr "" + +#: 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/window.py:1975 +#, python-format +msgid "Force close %s" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/phone_camera.py, line: 812 +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1606 #, python-format -msgid "Port %d is already in use" -msgstr "" +msgid "Found %s — enter the 6-digit code below" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 247 -msgid "Brightness / Contrast" -msgstr "" +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1555 +#, python-format +msgid "Found: %s" +msgstr "" -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 251 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 21 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py, line: 79 -msgid "Brightness" -msgstr "" +#: usr/share/biglinux/bigcam/core/phone_strings.py:11 +msgid "Frames per second" +msgstr "" -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 252 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 22 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py, line: 90 -msgid "Contrast" -msgstr "" +#: 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/window.py:594 +msgid "Fullscreen (F11)" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:32 +msgid "Gain" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:49 +msgid "Gallery status" +msgstr "" + +#: 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/core/effects.py:260 +msgid "Gamma Correction" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:103 +msgid "General" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py:211 +msgid "Generic Camera" +msgstr "" + +#: 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:1002 +msgid "Go to Settings → Developer Options → enable 'USB Debugging'" +msgstr "" + +#: usr/share/biglinux/bigcam/core/effects.py:320 +msgid "Grayscale" +msgstr "" + +#: usr/share/biglinux/bigcam/core/effects.py:277 +msgid "Grid Size" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:274 +msgid "Grid overlay" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:37 +msgid "Grid view" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2624 +msgid "HTTPS/WebSocket server for phone camera" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:371 +msgid "Hardware defaults" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:406 +msgid "Hidden" +msgstr "" + +#: usr/share/biglinux/bigcam/core/phone_strings.py:12 +msgid "High" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:81 +msgid "High resource usage detected" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2523 +msgid "High resource usage detected. Optimized automatically." +msgstr "" + +#: 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:838 +msgid "How to connect your phone" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:27 +msgid "Hue" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:117 +msgid "I understand, continue" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:431 +msgid "IP:Port (shown on phone screen)" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:46 +msgid "ISO Sensitivity" +msgstr "" + +#: 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: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/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/camera_controls_page.py:24 +msgid "Image" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:45 +msgid "Image Stabilization" +msgstr "" + +#: usr/share/biglinux/bigcam/core/phone_strings.py:13 +msgid "Include microphone audio" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/effects_page.py:70 +msgid "Install python-opencv to enable video effects." +msgstr "" + +#: 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:817 +#, python-format +msgid "Install required: %s" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 260 -msgid "Gamma Correction" -msgstr "" +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:786 +msgid "Instructions" +msgstr "" -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 264 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 26 -msgid "Gamma" -msgstr "" +#: usr/share/biglinux/bigcam/core/phone_camera.py:129 +msgid "Invalid server port" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 272 -msgid "CLAHE (Adaptive Contrast)" -msgstr "" +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:418 +msgid "Issuer" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 276 -msgid "Clip Limit" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:2401 +msgid "Keep camera on" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:51 +msgid "LED Frequency" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:50 +msgid "LED Mode" +msgstr "" + +#: 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/window.py:745 +msgid "Last video" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:407 +msgid "Latitude" +msgstr "" + +#: 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/welcome_dialog.py:154 +msgid "Let's Start" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:146 +msgid "Light" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:38 +msgid "List view" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/preview_area.py:523 +msgid "Live" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1045 +msgid "Load Profile" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:62 +msgid "Load more" +msgstr "" + +#: 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/camera_controls_page.py:139 +msgid "Loading controls…" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:105 +msgid "Loading media…" +msgstr "" + +#: 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/qr_dialog.py:408 +msgid "Longitude" +msgstr "" + +#: usr/share/biglinux/bigcam/core/phone_strings.py:12 +msgid "Low" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:336 +msgid "Main menu" +msgstr "" + +#: 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/camera_controls_page.py:342 +msgid "Manage" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:343 +msgid "Manage profiles" +msgstr "" + +#: 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:268 +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:485 +msgid "Maximum" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:597 +msgid "Maximum virtual cameras" +msgstr "" + +#: usr/share/biglinux/bigcam/core/phone_strings.py:12 +msgid "Medium" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:334 +msgid "Menu" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:396 +msgid "Message" +msgstr "" + +#: 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/welcome_dialog.py:76 +msgid "Mirror Preview" +msgstr "" + +#: 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:1028 +msgid "Mirrors the entire screen (not just the camera)." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:68 +msgid "Module" +msgstr "" + +#: 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/virtual_camera_page.py:131 +msgid "Module not available for current kernel" +msgstr "" + +#: 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/media_gallery.py:249 +#, python-format +msgid "Move %d item to Trash?" +msgid_plural "Move %d items to Trash?" +msgstr[0] "" +msgstr[1] "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 277 -msgid "Grid Size" -msgstr "" +#: usr/share/biglinux/bigcam/ui/media_gallery.py:168 +#, python-format +msgid "Move %s to Trash" +msgstr "" + +#: 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/welcome_dialog.py:71 +msgid "Multiple Cameras" +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:461 +#: usr/share/biglinux/bigcam/ui/preview_area.py:463 +msgid "Mute" +msgstr "" + +#: 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/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/core/effects.py:338 +msgid "Negative" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:403 +msgid "Network (SSID)" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1411 +msgid "No USB device connected" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2106 +msgid "No active camera stream." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:187 +msgid "No adjustable controls" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:951 +msgid "No cable needed after the first setup." +msgstr "" + +#: 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/camera_controls_page.py:97 +msgid "No camera selected" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1675 +msgid "No camera selected." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:1009 +msgid "No cameras connected" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1716 +msgid "No device available to start" +msgstr "" + +#: 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/phone_camera_dialog.py:1784 +msgid "No device found. Pair a device first, then tap 'Scan'." +msgstr "" + +#: 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/phone_camera_dialog.py:1662 +msgid "No devices found after pairing" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1549 +msgid "No devices found. Try 'Pair new device'." +msgstr "" + +#: 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/media_gallery.py:232 +msgid "No media yet" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2215 +msgid "No profiles found." +msgstr "" + +#: 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/phone_camera_dialog.py:605 +msgid "None" +msgstr "" + +#: 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/virtual_camera_page.py:137 +msgid "Not installed" +msgstr "" + +#: 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/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:395 +msgid "Number" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1837 +msgid "OK" +msgstr "" + +#: 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/phone_camera_dialog.py:969 +msgid "On the Wi-Fi tab, expand 'Pair new device'" +msgstr "" + +#: 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/phone_camera_dialog.py:1034 +msgid "On your iPhone, open Control Center (swipe down from top-right)" +msgstr "" + +#: 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/phone_camera_dialog.py:416 +msgid "Only needed once per device" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:156 +#, python-format +msgid "Open %s" +msgstr "" -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 285 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 27 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py, line: 112 -msgid "Auto White Balance" -msgstr "" +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:477 +msgid "Open E-mail Client" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 295 -msgid "Sharpen" -msgstr "" +#: usr/share/biglinux/bigcam/ui/media_gallery.py:69 +msgid "Open folder" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 299 -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 311 -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 376 -msgid "Strength" -msgstr "" +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:444 +msgid "Open in Browser" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:503 +msgid "Open in Maps" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:420 +msgid "Open last photo" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:70 +msgid "Open media folder" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:116 +msgid "Open photos folder" +msgstr "" + +#: 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/settings_page.py:133 +msgid "Open videos folder" +msgstr "" + +#: 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/resource_warning_dialog.py:119 +msgid "Optimize (disable heavy features)" +msgstr "" + +#: 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:401 +msgid "Organization" +msgstr "" + +#: 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/phone_camera_dialog.py:437 +msgid "Pair" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:415 +msgid "Pair new device" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1631 +msgid "Pairing…" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:40 +msgid "Pan" +msgstr "" + +#: 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:236 +msgid "Payment (PIX)" +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:159 +msgid "Phone" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2378 +msgid "Phone (AirPlay)" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2370 +msgid "Phone (Browser Wi-Fi)" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2374 +msgid "Phone (USB/Wi-Fi scrcpy)" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/controllers/mobile_device_ctrl.py:82 +msgid "Phone Mic (Browser)" +msgstr "" + +#: 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/window.py:1050 +msgid "Phone as Webcam…" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:178 +msgid "Phone camera" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2623 +msgid "Phone camera server" +msgstr "" + +#: usr/share/biglinux/bigcam/core/stream_engine.py:1485 +msgid "Phone camera server not available." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:185 +msgid "Phone camera status" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:68 +msgid "Photo & Video Capture" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:110 +msgid "Photo directory" +msgstr "" + +#: 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/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/window.py:666 +msgid "Photos" +msgstr "" + +#: 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/phone_camera_dialog.py:712 +msgid "Port" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:42 +msgid "Power Line Frequency" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:245 +msgid "Preview" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1689 +msgid "Preview screenshot" +msgstr "" + +#: 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/window.py:2227 +#, python-format +msgid "Profile loaded: %s" +msgstr "" + +#: 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/window.py:2205 +msgid "Profile saved." +msgstr "" + +#: 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:327 +msgid "QR Code Detected" +msgstr "" + +#: 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/window.py:1667 +msgid "QR Code detected!" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:676 +msgid "QR Code — scan with your phone" +msgstr "" + +#: 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/phone_camera_dialog.py:954 +msgid "Quick method" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1057 +msgid "Quit" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:220 +msgid "Re-enable all dismissed resource usage warnings." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1583 +msgid "Ready to start camera" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:84 +msgid "Real-Time Effects" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2583 +msgid "Real-time filters, background blur, artistic effects" +msgstr "" + +#: 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/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/window.py:1040 +msgid "Record Video" +msgstr "" + +#: 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/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/media_gallery.py:33 +msgid "Recorded Videos" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:416 +msgid "Recording" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2168 +#, python-format +msgid "Recording failed. Any partial file has been preserved: %s" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 307 -msgid "Denoise" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:2142 +msgid "Recording…" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 320 -msgid "Grayscale" -msgstr "" +#: usr/share/biglinux/bigcam/ui/settings_page.py:236 +msgid "Reduce motion and flashes" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 329 -msgid "Sepia" -msgstr "" +#: 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 "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 338 -msgid "Negative" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:324 +msgid "Refresh camera list" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 347 -msgid "Edge Detection" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:322 +msgid "Refresh cameras" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 351 -msgid "Threshold 1" -msgstr "" +#: usr/share/biglinux/bigcam/ui/media_gallery.py:66 +msgid "Refresh gallery" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 352 -msgid "Threshold 2" -msgstr "" +#: usr/share/biglinux/bigcam/ui/settings_page.py:225 +msgid "Reset" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 360 -msgid "Color Map" -msgstr "" +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:573 +#, python-format +msgid "Reset %s controls" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 364 -msgid "Style" -msgstr "" +#: usr/share/biglinux/bigcam/ui/effects_page.py:111 +#, python-format +msgid "Reset %s effects" +msgstr "" + +#: 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:350 +msgid "Reset camera settings" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:228 +msgid "Reset dismissed resource warnings" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:105 +msgid "Reset general settings" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:247 +msgid "Reset preview settings" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:418 +msgid "Reset recording settings" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:219 +msgid "Reset resource warnings" +msgstr "" + +#: 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/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:188 +#: usr/share/biglinux/bigcam/ui/settings_page.py:196 +msgid "Resource usage monitor" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:602 +msgid "Rotation" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:93 +msgid "SMS" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:26 +msgid "Saturation" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:422 +msgid "Save" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:512 +msgid "Save .ics File" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:521 +msgid "Save Contact" +msgstr "" + +#: 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/camera_controls_page.py:348 +msgid "Save current settings as new profile" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:351 +msgid "Save profile" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:574 +msgid "Save to File" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:705 +#, python-format +msgid "Saved: %s" +msgstr "" + +#: 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: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/welcome_dialog.py:80 +msgid "" +"Scan QR codes and barcodes\n" +"directly from the camera feed" +msgstr "" + +#: 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/phone_camera_dialog.py:1653 +msgid "Scanning for paired device…" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1535 +msgid "Scanning network…" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1533 +msgid "Scanning…" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:48 +msgid "Scene Mode" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2633 +msgid "Scrcpy (Android camera)" +msgstr "" + +#: 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/qr_dialog.py:417 +msgid "Secret" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:405 +msgid "Security" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:162 +msgid "Select" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/media_gallery.py:163 +#, python-format +msgid "Select %s" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/effects.py, line: 372 -msgid "Vignette" -msgstr "" +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:98 +msgid "Select a camera to see its controls." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/stream_engine.py, line: 779 -msgid "Failed to start camera streaming process." -msgstr "" +#: usr/share/biglinux/bigcam/ui/camera_selector.py:64 +msgid "Select camera" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/stream_engine.py, line: 793 -msgid "Failed to obtain GStreamer source for this camera." -msgstr "" +#: usr/share/biglinux/bigcam/ui/media_gallery.py:75 +msgid "Select displayed items" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/stream_engine.py, line: 878 -# File: bigcam/usr/share/biglinux/bigcam/core/stream_engine.py, line: 1234 -# File: bigcam/usr/share/biglinux/bigcam/core/stream_engine.py, line: 1245 -# File: bigcam/usr/share/biglinux/bigcam/core/stream_engine.py, line: 1523 -msgid "Failed to start camera stream." -msgstr "" +#: usr/share/biglinux/bigcam/ui/media_gallery.py:43 +#: usr/share/biglinux/bigcam/ui/media_gallery.py:44 +msgid "Select items" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/stream_engine.py, line: 1995 -msgid "Phone camera server not available." -msgstr "" +#: usr/share/biglinux/bigcam/ui/settings_page.py:579 +msgid "Select which cameras should output to a virtual device." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/stream_engine.py, line: 2203 -msgid "Unknown GStreamer error" -msgstr "" +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1424 +msgid "Selected device is no longer available. Tap 'Refresh' to update." +msgstr "" -# -# #-#-#-#-# bigcam.pot (bigcam) #-#-#-#-# -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 23 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py, line: 101 -msgid "Saturation" -msgstr "" +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1793 +msgid "Selected device is no longer available. Tap 'Scan' to refresh." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 24 -msgid "Hue" -msgstr "" +#: usr/share/biglinux/bigcam/ui/tools_page.py:115 +msgid "Sensitivity" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 25 -msgid "Sharpness" -msgstr "" +#: usr/share/biglinux/bigcam/core/effects.py:329 +msgid "Sepia" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 28 -msgid "White Balance Temperature" -msgstr "" +#: usr/share/biglinux/bigcam/core/phone_camera.py:144 +msgid "Server did not start in time" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 29 -msgid "Gain" -msgstr "" +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1242 +#, python-format +msgid "Server listening on port %d" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 30 -msgid "Auto Exposure" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:668 +msgid "Settings" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 31 -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 32 -msgid "Exposure Time" -msgstr "" +#: usr/share/biglinux/bigcam/core/effects.py:295 +msgid "Sharpen" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 33 -msgid "Exposure Auto Priority" -msgstr "" +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:28 +msgid "Sharpness" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 34 -msgid "Auto Focus" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:1910 +#: usr/share/biglinux/bigcam/ui/window.py:2276 +msgid "Show" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 35 -msgid "Focus Distance" -msgstr "" +#: usr/share/biglinux/bigcam/ui/settings_page.py:263 +#: usr/share/biglinux/bigcam/ui/settings_page.py:268 +msgid "Show FPS counter" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 36 -msgid "Zoom" -msgstr "" +#: usr/share/biglinux/bigcam/ui/settings_page.py:275 +msgid "Show a rule-of-thirds grid over the preview." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 37 -msgid "Pan" -msgstr "" +#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:143 +msgid "Show dialog on startup" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 38 -msgid "Tilt" -msgstr "" +#: usr/share/biglinux/bigcam/ui/settings_page.py:173 +#: usr/share/biglinux/bigcam/ui/settings_page.py:181 +msgid "Show help on hover" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 39 -msgid "Power Line Frequency" -msgstr "" +#: usr/share/biglinux/bigcam/ui/settings_page.py:174 +msgid "Show tooltip hints when hovering over buttons." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 40 -msgid "Exposure Bias" -msgstr "" +#: usr/share/biglinux/bigcam/ui/preview_area.py:311 +msgid "Show volume controls" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 41 -msgid "WB Preset" -msgstr "" +#: 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/tools_page.py:104 +msgid "Smile Capture" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/tools_page.py:384 +msgid "Smile detected! Capturing..." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:283 +msgid "Social Network" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:411 +msgid "Some profile controls could not be applied." +msgstr "" + +#: 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/window.py:699 +#: usr/share/biglinux/bigcam/ui/window.py:2148 +msgid "Start recording" +msgstr "" + +#: 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/phone_camera_dialog.py:1944 +msgid "Starting AirPlay…" +msgstr "" + +#: 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/window.py:2128 +msgid "Starting recording…" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1227 +msgid "Starting server…" +msgstr "" + +#: 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/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/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/window.py:2400 +msgid "Stop camera and close" +msgstr "" + +#: 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/window.py:1376 +msgid "Streaming failed" +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" +msgstr "" + +#: usr/share/biglinux/bigcam/core/effects.py:364 +msgid "Style" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:420 +msgid "Subject" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:410 +msgid "Summary" +msgstr "" + +#: 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:72 +msgid "" +"Switch between USB, IP, and\n" +"virtual cameras with hotplug support" +msgstr "" + +#: usr/share/biglinux/bigcam/core/phone_strings.py:9 +msgid "Switch camera" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:370 +msgid "Switch device to Wi-Fi mode" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1734 +msgid "Switching to Wi-Fi…" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:1784 +msgid "Switching to photography mode." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:146 +msgid "System" +msgstr "" + +#: 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/phone_camera_dialog.py:1001 +msgid "Tap 'Build Number' 7 times to unlock Developer Options" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:966 +msgid "Tap 'Pair device with pairing code'" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1035 +msgid "Tap 'Screen Mirroring' and select 'BigCam'" +msgstr "" + +#: 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/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/resource_warning_dialog.py:85 +#, python-format +msgid "The application is using %s and %s." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 42 -msgid "Image Stabilization" -msgstr "" +#: 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 "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 43 -msgid "ISO Sensitivity" -msgstr "" +#: 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/camera_controls_page.py:549 +msgid "The camera could not apply this setting." +msgstr "" + +#: 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/phone_camera_dialog.py:996 +msgid "The easiest and fastest method." +msgstr "" + +#: 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/core/phone_camera.py:134 +msgid "The previous server session is still stopping." +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." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:143 +msgid "Theme" +msgstr "" + +#: 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/camera_controls_page.py:188 +msgid "This camera does not expose any controls." +msgstr "" + +#: 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/core/effects.py:351 +msgid "Threshold 1" +msgstr "" + +#: usr/share/biglinux/bigcam/core/effects.py:352 +msgid "Threshold 2" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:41 +msgid "Tilt" +msgstr "" + +#: 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/qr_dialog.py:402 +msgid "Title" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:554 +msgid "Toggle grid overlay" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:321 +msgid "Transparency of the buttons over the preview." +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:1731 +#: usr/share/biglinux/bigcam/ui/window.py:1966 +msgid "Try again" +msgstr "" + +#: 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/qr_dialog.py:419 +msgid "URI" +msgstr "" + +#: 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/phone_camera_dialog.py:130 +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1011 +msgid "USB" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:995 +msgid "USB (Android)" +msgstr "" + +#: 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/core/stream_engine.py:1656 +msgid "Unknown GStreamer error" +msgstr "" + +#: 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/virtual_camera_page.py:99 +msgid "Usage" +msgstr "" + +#: 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:166 +msgid "Use your phone as a webcam" +msgstr "" + +#: 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:924 +msgid "Use your window manager to keep this window above others." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:518 +msgid "Video Bitrate (kbps)" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:424 +msgid "Video Codec" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:127 +msgid "Video directory" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2582 +msgid "Video effects" +msgstr "" + +#: 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:2610 +msgid "Video recording" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2164 +#, python-format +msgid "Video saved: %s" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 44 -msgid "Auto ISO" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:667 +msgid "Videos" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 45 -msgid "Scene Mode" -msgstr "" +#: usr/share/biglinux/bigcam/core/effects.py:372 +msgid "Vignette" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 46 -msgid "3A Lock" -msgstr "" +#: 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 "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 47 -msgid "LED Mode" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:1834 +msgid "Virtual Camera Limit Reached" +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/v4l2_backend.py, line: 48 -msgid "LED Frequency" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:2078 +msgid "Virtual Camera enabled. Other applications can use /dev/video10." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/libcamera_backend.py, line: 130 -msgid "Exposure Mode" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:2390 +msgid "Virtual Camera is enabled (other apps may depend on it)." +msgstr "" -# -# File: bigcam/usr/share/biglinux/bigcam/core/backends/gphoto2_backend.py, line: 289 -msgid "Generic Camera" -msgstr "" +#: usr/share/biglinux/bigcam/ui/window.py:1440 +#, python-brace-format +msgid "Virtual Camera: {vcam_device} Created!" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/settings_page.py:578 +msgid "Virtual Cameras" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2591 +msgid "Virtual camera" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:2602 +msgid "Virtual camera feeds for inactive cameras" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:53 +msgid "Virtual camera status" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:574 +msgid "Visible name" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/preview_area.py:310 +msgid "Volume" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:44 +msgid "WB Preset" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:2005 +msgid "Waiting for AirPlay connection…" +msgstr "" + +#: 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/settings_page.py:189 +msgid "Warn when CPU or memory usage is high." +msgstr "" + +#: 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:1052 +msgid "Welcome Screen" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/welcome_dialog.py:47 +msgid "Welcome to BigCam" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:472 +msgid "WhatsApp" +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." +msgstr "" + +#: usr/share/biglinux/bigcam/ui/camera_controls_page.py:27 +msgid "White Balance" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:31 +msgid "White Balance Temperature" +msgstr "" + +#: 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/phone_camera_dialog.py:950 +msgid "Wi-Fi (Android 11+)" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1750 +msgid "Wi-Fi switch failed" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:1745 +#, python-format +msgid "Wi-Fi: %s (unplug USB)" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/phone_camera_dialog.py:920 +msgid "Works with any phone, no app required." +msgstr "" + +#: 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/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: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: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/welcome_dialog.py:55 +msgid "Your universal webcam control center for Linux" +msgstr "" + +#: usr/share/biglinux/bigcam/core/backends/v4l2_backend.py:39 +msgid "Zoom" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/window.py:539 +msgid "Zoom level" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/resource_warning_dialog.py:90 +msgid "active source" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:643 +msgid "iCalendar Files" +msgstr "" + +#: usr/share/biglinux/bigcam/core/phone_camera.py:127 +msgid "python-aiohttp is not installed" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/virtual_camera_page.py:69 +msgid "v4l2loopback" +msgstr "" + +#: 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:2592 +msgid "v4l2loopback output for video conferencing" +msgstr "" + +#: usr/share/biglinux/bigcam/ui/qr_dialog.py:669 +msgid "vCard Files" +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/virtual_camera_page.py:138 +msgid "—" +msgstr "" diff --git a/locale/pt-BR.po b/locale/pt-BR.po index c54789a..074bc80 100644 --- a/locale/pt-BR.po +++ b/locale/pt-BR.po @@ -3,12 +3,14 @@ msgid "" msgstr "" "Project-Id-Version: bigcam\n" "Report-Msgid-Bugs-To: \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" #: /home/ruscher/Documentos/Git/_REFIZ/bigcam/usr/share/biglinux/bigcam/core/phone_camera.py:560 msgid "python-aiohttp is not installed" @@ -2535,6 +2537,389 @@ msgstr "Reinicialização necessária (kernel atualizado)" 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 "About connection methods" #~ msgstr "Sobre métodos de conexão" diff --git a/pkgbuild/PKGBUILD b/pkgbuild/PKGBUILD index 68bc3b3..4e65696 100644 --- a/pkgbuild/PKGBUILD +++ b/pkgbuild/PKGBUILD @@ -9,15 +9,16 @@ depends=( 'python-gobject' 'python-opencv' 'python-numpy' + 'python-pillow' 'python-aiohttp' 'python-qrcode' # GTK4 / UI - 'gtk4' - 'libadwaita' + 'gtk4>=4.12' + 'libadwaita>=1.5' # GStreamer core + plugins - 'gstreamer' + 'gstreamer>=1.20' 'gst-plugins-base' 'gst-plugins-good' 'gst-plugins-bad' @@ -29,7 +30,7 @@ depends=( # Camera backends & virtual camera 'v4l-utils' 'v4l2loopback-dkms' - 'linux-headers' + 'v4l2loopback-utils' 'gphoto2' 'libcamera' 'libcamera-tools' @@ -59,12 +60,14 @@ depends=( 'psmisc' 'usbutils' ) -pkgver=$(date +%y.%m.%d) -pkgrel=$(date +%H%M) +epoch=1 +pkgver=4.5.0 +pkgrel=2 arch=('any') license=('GPL3') url="https://github.com/biglinux/$pkgname" -backup=('etc/sudoers.d/bigcam' 'etc/modprobe.d/v4l2loopback.conf') +backup=('etc/modprobe.d/v4l2loopback.conf') +makedepends=('git' 'gettext') provides=("$pkgname") source=("git+${url}.git") sha256sums=('SKIP') diff --git a/pkgbuild/pkgbuild.install b/pkgbuild/pkgbuild.install index 301f048..97246d8 100644 --- a/pkgbuild/pkgbuild.install +++ b/pkgbuild/pkgbuild.install @@ -1,30 +1,30 @@ -## arg 1: the new package version -#pre_install() { - # do something here -#} +pre_upgrade() { + # New helper is not installed yet: match only the eight old shipped rules. + local policy=/etc/sudoers.d/bigcam + [[ -f $policy && ! -L $policy ]] || return 0 + local tmp + tmp=$(mktemp /etc/sudoers.d/.bigcam-upgrade.XXXXXX) || return 1 + awk '!/^%wheel ALL=\(root\) NOPASSWD: \/usr\/(sbin|bin)\/(modprobe v4l2loopback \*|modprobe -r v4l2loopback|v4l2loopback-ctl (add|delete) \*)$/' "$policy" > "$tmp" + chmod 0440 "$tmp" + if ! cmp -s "$policy" "$tmp"; then + if visudo -cf "$tmp"; then + install -d -m 0700 /var/lib/bigcam/policy-backups + install -m 0600 "$policy" "/var/lib/bigcam/policy-backups/sudoers-$(date +%s)-$$" + mv -f "$tmp" "$policy" + else + echo 'ERROR: legacy BigCam sudoers migration failed; remove the unsafe rules manually.' >&2 + rm -f "$tmp" + return 1 + fi + else + rm -f "$tmp" + fi +} -## arg 1: the new package version -#post_install() { - # do something here -#} +post_install() { + /usr/bin/python3 -I /usr/share/biglinux/bigcam/script/migrate-legacy-policy.py +} -## arg 2: the old package version -#pre_upgrade() { - # do something here -#} - -## arg 2: the old package version -#post_upgrade() { - #post_install -#} - -## arg 1: the old package version -#pre_remove() { - # do something here -#} - -## arg 1: the old package version -#post_remove() { - # do something here -#} - +post_upgrade() { + post_install +} diff --git a/tests/audit/conftest.py b/tests/audit/conftest.py new file mode 100644 index 0000000..497fa4a --- /dev/null +++ b/tests/audit/conftest.py @@ -0,0 +1,17 @@ +"""Audit regression tests use only disposable user data, never real devices.""" +from pathlib import Path +import sys +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "usr/share/biglinux/bigcam")) + +@pytest.fixture(autouse=True) +def private_home(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + for name, directory in [("XDG_CONFIG_HOME", "config"), ("XDG_CACHE_HOME", "cache"), + ("XDG_DATA_HOME", "data"), ("XDG_STATE_HOME", "state")]: + monkeypatch.setenv(name, str(tmp_path / directory)) + from utils import xdg + xdg._user_dir.cache_clear() + monkeypatch.setattr(xdg, "_user_dir", lambda kind, fallback: str(tmp_path / kind)) diff --git a/tests/audit/test_media_protocol.py b/tests/audit/test_media_protocol.py new file mode 100644 index 0000000..7d42107 --- /dev/null +++ b/tests/audit/test_media_protocol.py @@ -0,0 +1,134 @@ +"""Pure media/transport contracts: no display, devices, network or credentials.""" +import io +import math +import random +from types import SimpleNamespace +from PIL import Image +import numpy as np +import pytest + +from constants import BackendType +from core.camera_identity import unique_cameras +from core.phone_protocol import StreamAssembler, jpeg_size, valid_token, MAX_FRAME_BYTES +from core.recording_config import RecordingConfig +from utils.frame_buffers import bgr_from_bgra, LatestValue +from utils.video_formats import frame_rate, source_caps + + +@pytest.mark.parametrize("fps,expected", [(29.970,"30000/1001"),(59.940,"60000/1001"),(23.976,"24000/1001"),(25,"25/1"),(15.5,"31/2")]) +def test_fractional_frame_rates(fps, expected): + assert frame_rate(fps) == expected + +@pytest.mark.parametrize("fps", [0,-1,float("nan"),float("inf"),2000]) +def test_invalid_rates(fps): + with pytest.raises(ValueError): frame_rate(fps) + +@pytest.mark.parametrize("fourcc,prefix", [("MJPG","image/jpeg"),("H264","video/x-h264"),("HEVC","video/x-h265"),("YUYV","video/x-raw,format=YUY2")]) +def test_caps(fourcc,prefix): + caps, _ = source_caps(SimpleNamespace(pixel_format=fourcc,width=640,height=480,fps=[29.97])) + assert caps.startswith(prefix) and "30000/1001" in caps + +def test_unknown_fourcc_is_not_declared_raw(): + with pytest.raises(ValueError): source_caps(SimpleNamespace(pixel_format="????")) + +def test_names_do_not_deduplicate_devices(): + def cam(ident,path,backend=BackendType.V4L2): + return SimpleNamespace(id=ident,name="Same camera",device_path=path,backend=backend,extra={}) + assert len(unique_cameras([cam("a","/dev/video0"),cam("b","/dev/video2")])) == 2 + result=unique_cameras([cam("pipe","/dev/video0",BackendType.PIPEWIRE),cam("v4l","/dev/video0")]) + assert len(result)==1 and result[0].id=="v4l" + +def test_padded_video_is_copied_before_unmap(): + raw = bytearray(48) + raw[4:12] = bytes([1,2,3,255,4,5,6,255]) + raw[24:32] = bytes([7,8,9,255,10,11,12,255]) + frame = bgr_from_bgra(raw,2,2,20,4) + assert frame.tolist()==[[[1,2,3],[4,5,6]],[[7,8,9],[10,11,12]]] + raw[4]=99 + assert frame[0,0,0]==1 + +@pytest.mark.parametrize("args", [(b"",1,1,4), (b"1234",2,1,8),(b"1234",1,1,3)]) +def test_invalid_video_layout(args): + with pytest.raises(ValueError): bgr_from_bgra(*args) + +def test_latest_preview_slot_has_one_callback(): + slot=LatestValue() + assert slot.publish(0) + for number in range(1,10000): assert not slot.publish(number) + assert slot.take()==9999 + assert slot.publish(1) + slot.clear() + assert not slot.publish(2) + assert slot.take()==2 + assert slot.publish(3) + +@pytest.mark.parametrize("video",["h264","h265","vp9","mjpeg"]) +@pytest.mark.parametrize("audio",["aac","opus","vorbis","mp3"]) +def test_webm_compatibility_is_bidirectional(video,audio): + config=RecordingConfig(video,audio,"webm",7000) + assert config.video_codec=="vp9" and config.audio_codec in {"opus","vorbis"} + assert "7000000" in config.encoders()[-1][1] + +def test_mp4_valid_codecs_are_preserved(): + assert RecordingConfig("vp9","opus","mp4").video_codec=="vp9" + assert RecordingConfig("mjpeg","vorbis","mp4").audio_codec=="aac" + +@pytest.mark.parametrize("bitrate",[float("nan"),float("inf"),True]) +def test_bad_recording_bitrate(bitrate): + with pytest.raises(ValueError): RecordingConfig(video_bitrate=bitrate) + +@pytest.mark.parametrize("value",[None,"","é",b"secret","X"*129]) +def test_token_rejects_untrusted_types(value): + assert not valid_token(value,"secret") + +def test_token_exact_match(): + assert valid_token("secret","secret") and not valid_token("Secret","secret") + +def test_stream_authentication_and_reset(): + streams=StreamAssembler(4) + with pytest.raises(PermissionError): streams.feed(8,2,b"a",False) + assert streams.feed(4,2,b"a",False) is None + assert streams.feed(4,2,b"bc",True)==b"abc" + assert streams.total==0 and not streams.streams + streams.feed(4,6,b"old",False); streams.discard(6) + assert streams.feed(4,6,b"new",True)==b"new" + +def test_stream_limits(): + s=StreamAssembler(4) + for i in range(8): s.feed(4,i,b"x",False) + with pytest.raises(ValueError): s.feed(4,9,b"x",False) + assert len(s.streams)==8 + s=StreamAssembler(4) + with pytest.raises(ValueError): s.feed(4,1,b"x"*(MAX_FRAME_BYTES+1),False) + assert s.total==0 + +def test_expired_stream(): + now=[0.0]; s=StreamAssembler(4,lambda:now[0]);s.feed(4,2,b"a",False) + now[0]=3.1 + with pytest.raises(ValueError): s.feed(4,2,b"b",True) + +def test_jpeg_dimensions_before_decoding(): + stream=io.BytesIO();Image.new("RGB",(82,42)).save(stream,format="JPEG") + assert jpeg_size(stream.getvalue())==(82,42) + with pytest.raises(ValueError): jpeg_size(b"not an image") + +def test_seeded_fragmentation_fuzz(): + rng=random.Random(73001) + for _ in range(2000): + data=rng.randbytes(rng.randrange(0,2048));s=StreamAssembler(4);cut=rng.randrange(len(data)+1) + assert s.feed(4,2,data[:cut],False) is None + assert s.feed(4,2,data[cut:],True)==data + assert not s.streams and s.total==0 + + +def test_certificate_is_private_short_lived_and_reused(tmp_path): + from core.phone_tls import ensure_certificate + from cryptography import x509 + from cryptography.hazmat.primitives.asymmetric import ec + from pathlib import Path + cert,key,digest=ensure_certificate(tmp_path/"tls") + x=x509.load_pem_x509_certificate(Path(cert).read_bytes()) + assert isinstance(x.public_key(),ec.EllipticCurvePublicKey) + assert (x.not_valid_after_utc-x.not_valid_before_utc).days<=14 + assert Path(key).stat().st_mode&0o777==0o600 + assert ensure_certificate(tmp_path/"tls")== (cert,key,digest) diff --git a/tests/audit/test_persistence.py b/tests/audit/test_persistence.py new file mode 100644 index 0000000..a4d01bb --- /dev/null +++ b/tests/audit/test_persistence.py @@ -0,0 +1,176 @@ +from concurrent.futures import ThreadPoolExecutor +import importlib.util +import json +import multiprocessing +import os +from pathlib import Path +import stat +from types import SimpleNamespace + +import pytest + +from constants import BackendType +from core.camera_backend import CameraInfo +from core import camera_profiles as profiles +from utils import atomic_json, xdg +from utils.settings_manager import SettingsManager +from utils.media_paths import reserve_media_path, reserve_named_path + + +def _set_from_process(index): + assert SettingsManager().set(f"process-{index}", f"value-{index}") + + +def camera(name="Camera", ident="v4l2:serial-A"): + return CameraInfo(ident, name, BackendType.V4L2, "/dev/video0") + + +def test_independent_instances_merge_and_invalidate(): + a, b = SettingsManager(), SettingsManager() + assert a.set("theme", "light") + assert b.set("show-welcome", False) + assert a.get("show-welcome") is False + assert b.get("theme") == "light" + assert stat.S_IMODE(os.stat(a._path).st_mode) == 0o600 + + +def test_cross_process_updates_are_not_lost(): + context = multiprocessing.get_context("spawn") + processes = [context.Process(target=_set_from_process, args=(i,)) for i in range(12)] + for process in processes: + process.start() + for process in processes: + process.join(timeout=10) + assert process.exitcode == 0 + manager = SettingsManager() + assert all(manager.get(f"process-{i}") == f"value-{i}" for i in range(12)) + + +def test_mutable_values_are_defensive_copies(): + a, b = SettingsManager(), SettingsManager() + value = a.get("ip_cameras") + value.append({"name": "camera", "url": "https://example.invalid/"}) + assert b.get("ip_cameras") == [] + assert a.get("ip_cameras") == [] + assert a.set("ip_cameras", value) + value.clear() + assert len(b.get("ip_cameras")) == 1 + + +@pytest.mark.parametrize("raw", ['[]', 'null', 'false', '42', '"text"', '{broken', '{"x": NaN}']) +def test_malformed_roots_are_preserved_on_recovery(raw): + path = Path(xdg.config_dir()) / "settings.json" + path.write_text(raw) + manager = SettingsManager() + assert manager.get("theme") == "dark" + assert manager.set("theme", "light") + backups = list(path.parent.glob("settings.invalid-*.json")) + assert len(backups) == 1 + assert backups[0].read_text() == raw + assert atomic_json.read_object(path)["theme"] == "light" + + +def test_failed_write_does_not_truncate_previous_data(monkeypatch): + manager = SettingsManager() + assert manager.set("theme", "light") + before = Path(manager._path).read_bytes() + def fail(*args): + raise OSError("simulated disk error") + monkeypatch.setattr(atomic_json.os, "replace", fail) + assert manager.set("theme", "dark") is False + assert Path(manager._path).read_bytes() == before + assert not list(Path(manager._path).parent.glob(".bigcam-*.tmp")) + + +def test_settings_refuse_symlinks(tmp_path): + outside = tmp_path / "outside.json" + outside.write_text('{"theme":"light"}') + (Path(xdg.config_dir()) / "settings.json").symlink_to(outside) + manager = SettingsManager() + assert manager.set("theme", "dark") is False + assert json.loads(outside.read_text()) == {"theme": "light"} + + +@pytest.mark.parametrize("value,expected", [(999, 8), (-4, 1), (None, 5), ("bad", 5)]) +def test_ranges_and_types(value, expected): + manager = SettingsManager() + assert manager.set("vcam-max-devices", value) + assert manager.get("vcam-max-devices") == expected + manager.set("show-welcome", "false") + assert manager.get("show-welcome") is False + + +def test_profile_camera_dotdot_cannot_overwrite_settings(): + manager = SettingsManager() + manager.set("theme", "light") + before = Path(manager._path).read_bytes() + target = profiles.save_profile(camera(".."), "settings", [SimpleNamespace(id="brightness", value=7, flags="")]) + assert Path(target).resolve().is_relative_to(Path(xdg.profiles_dir()).resolve()) + assert Path(manager._path).read_bytes() == before + assert profiles.load_profile(camera(".."), "settings") == {"brightness": 7} + assert stat.S_IMODE(os.stat(target).st_mode) == 0o600 + + +def test_identical_camera_names_do_not_share_new_profiles(): + profiles.save_profile(camera("Same", "A"), "test", [SimpleNamespace(id="a", value=1, flags="")]) + assert profiles.load_profile(camera("Same", "B"), "test") == {} + + +def test_profile_follows_physical_camera_after_video_node_renumbering(): + original = camera("Same", "v4l2:/dev/video0") + original.extra["profile_id"] = "serial-A" + moved = camera("Same", "v4l2:/dev/video2") + moved.extra["profile_id"] = "serial-A" + replacement = camera("Same", "v4l2:/dev/video0") + replacement.extra["profile_id"] = "serial-B" + profiles.save_profile(original, "portrait", [SimpleNamespace(id="brightness", value=7, flags="")]) + assert profiles.load_profile(moved, "portrait") == {"brightness": 7} + assert profiles.load_profile(replacement, "portrait") == {} + + +@pytest.mark.parametrize("name", ["", ".", "..", "x" * 181]) +def test_invalid_profile_names_fail(name): + with pytest.raises(ValueError): + profiles.save_profile(camera(), name, []) + + +def test_profile_symlink_rejected(tmp_path): + path = Path(profiles._profile_path(camera(), "test")) + outside = tmp_path / "outside.json" + outside.write_text("{}") + path.symlink_to(outside) + with pytest.raises(ValueError): + profiles.save_profile(camera(), "test", []) + assert profiles.load_profile(camera(), "test") == {} + assert outside.read_text() == "{}" + + +def test_profile_skips_non_writable_controls(): + controls = [SimpleNamespace(id="a", value=1, flags="read-only, inactive"), + SimpleNamespace(id="b", value=2, flags=None)] + profiles.save_profile(camera(), "test", controls) + assert profiles.load_profile(camera(), "test") == {"b": 2} + + +def test_legacy_profiles_are_read_without_escaping(): + path = Path(xdg.profiles_dir()) / "Old_Camera" + path.mkdir() + (path / "default.json").write_text('{"brightness": 4}') + assert profiles.load_profile(camera("Old Camera"), "default") == {"brightness": 4} + + +def test_concurrent_capture_names_are_unique(tmp_path): + with ThreadPoolExecutor(max_workers=8) as pool: + names = list(pool.map(lambda _: reserve_media_path(str(tmp_path), ".png"), range(128))) + assert len(set(names)) == 128 + assert all(stat.S_IMODE(os.stat(p).st_mode) == 0o600 for p in names) + + +def test_named_capture_is_exclusive_and_confined(tmp_path): + path = reserve_named_path(str(tmp_path), "photo.jpg") + Path(path).write_bytes(b"original") + with pytest.raises(FileExistsError): + reserve_named_path(str(tmp_path), "photo.jpg") + with pytest.raises(ValueError): + reserve_named_path(str(tmp_path), "../photo.jpg") + assert Path(path).read_bytes() == b"original" diff --git a/tests/audit/test_security.py b/tests/audit/test_security.py new file mode 100644 index 0000000..1534d5d --- /dev/null +++ b/tests/audit/test_security.py @@ -0,0 +1,122 @@ +from contextlib import contextmanager +from importlib.machinery import SourceFileLoader +from importlib.util import spec_from_loader, module_from_spec +from pathlib import Path + +import pytest +from utils.urls import camera_url, camera_url_id, public_camera_name, gst_quote +from utils.command_runner import SecureCommandRunner +from core.backends.ip_backend import IPBackend + +ROOT = Path(__file__).resolve().parents[2] + + +def load_script(name, path): + loader = SourceFileLoader(name, str(ROOT / path)) + module = module_from_spec(spec_from_loader(name, loader)) + loader.exec_module(module) + return module + + +helper = load_script("bigcam_helper_test", "usr/lib/bigcam/virtual-camera-helper") +migration = load_script("bigcam_migration_test", "usr/share/biglinux/bigcam/script/migrate-legacy-policy.py") + + +@pytest.mark.parametrize("args", [[], ["load", "-C", "/tmp/evil"], ["unload"], + ["create", "../session", "name"], ["create", "f"*32, "name\nexec"], + ["create", "f"*32, "a"*32], ["delete", "f"*32, "/dev/video0"], + ["delete", "f"*32, "/dev/video20", "-f"], ["delete", "f"*32, "../../dev/video20"]]) +def test_privileged_argument_allowlist(args): + with pytest.raises(ValueError): + helper.validate(args) + + +def test_fixed_load_cannot_inherit_modprobe_options(monkeypatch): + monkeypatch.setenv("MODPROBE_OPTIONS", "-C /tmp/untrusted") + monkeypatch.setattr(helper.shutil, "which", lambda *a, **kw: "/usr/sbin/modprobe") + calls = [] + monkeypatch.setattr(helper.subprocess, "run", lambda *a, **kw: calls.append((a, kw))) + helper.run("modprobe", "--ignore-install", "--config", "/dev/null", "v4l2loopback", "devices=0") + assert calls[0][0][0] == ["/usr/sbin/modprobe", "--ignore-install", "--config", "/dev/null", "v4l2loopback", "devices=0"] + assert calls[0][1]["env"] == {"PATH": helper.SAFE_PATH, "LC_ALL": "C"} + assert "shell" not in calls[0][1] + + +@pytest.mark.parametrize("uid,session", [(1001, "a"*32), (1000, "b"*32)]) +def test_delete_requires_uid_and_session(monkeypatch, uid, session): + @contextmanager + def ledger(): + yield {"/dev/video20": {"uid": 1000, "session": "a"*32, "label": "BigCam"}} + monkeypatch.setattr(helper, "ledger", ledger) + monkeypatch.setattr(helper.Path, "exists", lambda self: True) + monkeypatch.setattr(helper, "run", lambda *a: pytest.fail("Must not invoke ctl")) + with pytest.raises(PermissionError): + helper.perform("delete", session, "/dev/video20", uid) + + +def test_migration_removes_only_known_rules(): + legacy = "%wheel ALL=(root) NOPASSWD: /usr/bin/modprobe v4l2loopback *\n" + custom = "%admin ALL=(root) /usr/bin/other\n# Administrator configuration\n" + assert migration.filtered(legacy + custom) == custom + + +@pytest.mark.parametrize("url", ["file:///etc/passwd", "https:///bad", "https://x:99999/", "https://[", "http://x/\nname"]) +def test_invalid_camera_urls(url): + with pytest.raises(ValueError): + camera_url(url) + + +def test_camera_ids_and_names_do_not_disclose_passwords(): + url = "RTSPS://alice:secret@example.invalid:8554/live?token=hidden" + assert camera_url(url).startswith("rtsps://") + assert public_camera_name(url) == "rtsps://example.invalid" + assert camera_url_id(url).startswith("ip:") + assert "secret" not in camera_url_id(url) + camera = IPBackend().cameras_from_urls([{"url": url, "name": url}])[0] + source = IPBackend().get_gst_source(camera) + assert source.startswith("rtspsrc ") + assert "secret" not in camera.name + + +def test_property_quoting_handles_backslashes_and_quotes(): + assert gst_quote('a\\b"c') == '"a\\\\b\\"c"' + with pytest.raises(ValueError): + gst_quote("a\n!") + + +def test_subprocess_rejects_shell_command_strings(): + with pytest.raises(ValueError): + SecureCommandRunner.run_safe("echo unexpected") + + +@pytest.mark.parametrize("alive,inode,owner,expected", [ + (False, 41, 1000, True), + (True, 41, 1000, False), + (False, 99, 1000, False), + (False, 41, 1001, False), +]) +def test_reclaim_only_dead_owned_device(monkeypatch, alive, inode, owner, expected): + records = {"/dev/video20": {"uid": owner, "session": "a" * 32, "label": "BigCam", + "pid": 123, "started": "old", "inode": 41}} + @contextmanager + def ledger(): + yield records + calls = [] + monkeypatch.setattr(helper, "ledger", ledger) + monkeypatch.setattr(helper.Path, "exists", lambda p: str(p) == "/dev/video20") + monkeypatch.setattr(helper, "current_label", lambda device: "BigCam") + monkeypatch.setattr(helper, "device_identity", lambda device: inode) + monkeypatch.setattr(helper.os, "getppid", lambda: 456) + monkeypatch.setattr(helper, "process_identity", lambda pid, uid: "new" if pid == 456 else ("old" if alive else None)) + monkeypatch.setattr(helper, "run", lambda *args: calls.append(args)) + monkeypatch.setattr(helper, "load", lambda: None) + helper.perform("create", "b" * 32, "BigCam", 1000) + assert (("v4l2loopback-ctl", "delete", "/dev/video20") in calls) is expected + assert records["/dev/video21"]["pid"] == 456 + + +def test_helper_process_identity_detects_pid_reuse(): + import os + assert helper.process_identity(os.getpid(), os.getuid()) + assert helper.process_identity(os.getpid(), os.getuid() + 1) is None + assert helper.process_identity(2147483647, os.getuid()) is None diff --git a/tests/chaos_hotplug.py b/tests/chaos_hotplug.py index c9925f8..2ccd75d 100644 --- a/tests/chaos_hotplug.py +++ b/tests/chaos_hotplug.py @@ -1,99 +1,12 @@ #!/usr/bin/env python3 -""" -Chaos Hotplug - Simula conexão e desconexão agressiva de dispositivos V4L2. -Objetivo: Garantir que o EventBus e a UI do BigCam não sofram deadlock. -""" +"""Safe chaos entry point: isolated regression tests, NEVER random /dev removals. -import os +Physical unplug/replug is a separate opt-in manual test; passing this runner is +not proof of hardware hotplug correctness. +""" +from pathlib import Path +import subprocess import sys -import time -import random -import threading -import logging -from typing import List - -# Ensure bigcam modules are importable -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../usr/share/biglinux/bigcam"))) - -from utils.command_runner import SecureCommandRunner - -logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") -log = logging.getLogger("ChaosMonkey") - -class ChaosHotplugger: - def __init__(self, num_devices=3): - self.num_devices = num_devices - self.runner = SecureCommandRunner() - self.active_devices: List[str] = [] - self.running = False - self.thread = None - - def start(self): - self.running = True - self.thread = threading.Thread(target=self._chaos_loop, daemon=True) - self.thread.start() - log.info("Chaos Monkey iniciado.") - - def stop(self): - self.running = False - if self.thread: - self.thread.join() - # Clean up - for dev in list(self.active_devices): - self._remove_device(dev) - log.info("Chaos Monkey finalizado.") - - def _add_device(self) -> str: - # We rely on pkexec / sudo rules being set up for v4l2loopback-ctl - dev_num = random.randint(50, 99) - dev_path = f"/dev/video{dev_num}" - if dev_path in self.active_devices: - return "" - - log.info(f"Adding chaos device: {dev_path}") - success, _, _ = self.runner.run_sync( - ["sudo", "-n", "v4l2loopback-ctl", "add", "-n", f"ChaosCam {dev_num}", dev_path], - timeout=5.0 - ) - if success: - self.active_devices.append(dev_path) - return dev_path - return "" - - def _remove_device(self, dev_path: str): - if dev_path in self.active_devices: - log.info(f"Removing chaos device: {dev_path}") - self.runner.run_sync( - ["sudo", "-n", "v4l2loopback-ctl", "delete", dev_path], - timeout=5.0 - ) - self.active_devices.remove(dev_path) - - def _chaos_loop(self): - while self.running: - action = random.choice(["add", "remove", "add", "add"]) - - if action == "add" and len(self.active_devices) < self.num_devices: - self._add_device() - elif action == "remove" and self.active_devices: - dev_to_remove = random.choice(self.active_devices) - self._remove_device(dev_to_remove) - - time.sleep(random.uniform(0.1, 1.5)) - -if __name__ == "__main__": - if os.geteuid() != 0 and not os.system("sudo -n true") == 0: - log.error("This test requires sudo-nopasswd for v4l2loopback-ctl.") - sys.exit(1) - - chaos = ChaosHotplugger(num_devices=5) - chaos.start() - - try: - log.info("Running hotplug chaos for 30 seconds...") - time.sleep(30) - except KeyboardInterrupt: - log.info("Interrupted by user") - finally: - chaos.stop() +if __name__=="__main__": + raise SystemExit(subprocess.call([sys.executable,"-m","pytest","-q",str(Path(__file__).parent/"audit")])) diff --git a/tests/integration/a11y_client.py b/tests/integration/a11y_client.py new file mode 100644 index 0000000..435c46a --- /dev/null +++ b/tests/integration/a11y_client.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Require BigCam in the real AT-SPI desktop; fail when the binding is absent.""" +import json +import time +import pyatspi + +def walk(node,depth=0): + data={"name":node.name,"role":node.getRoleName(),"children":[]} + if depth<12: + for child in node: + if child:data["children"].append(walk(child,depth+1)) + return data + +end=time.monotonic()+12 +while time.monotonic()=deadline,seconds+1,"main loop interval") +def capture(name): + subprocess.run(["scrot",str(RESULTS/name)],check=True,timeout=5) + +summary={"full_functional_certification":False,"physical_hardware_tested":False,"checks":{}} +app=win=None +try: + from main import BigDigicamApp + app=BigDigicamApp();assert app.register(None);app.activate() + pump_until(lambda: bool(app.get_windows()),label="application window") + win=app.get_windows()[0];pump_until(lambda:win.get_mapped(),label="mapped window") + summary["checks"]["window_opened"]=True + assert not app.get_accels_for_action("win.toggle-sidebar")==["Tab"] + win._on_ip_camera_added(None,"Synthetic HTTP",f"http://127.0.0.1:{server.server_port}/camera") + pump_until(lambda:win._stream_engine.last_frame_bgr is not None,20,"actual decoded frames") + pump(1) + assert win._stream_engine.is_playing() + assert win._preview._picture.get_paintable() is not None + summary["checks"]["preview_decoded_and_rendered"]=True + capture("01-preview.png") + win.activate_action("win.toggle-sidebar",None);pump(.2) + capture("02-controls.png") + win.activate_action("win.toggle-sidebar",None) + a11y=subprocess.Popen([sys.executable,str(ROOT/"tests/integration/a11y_client.py")],stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True) + pump_until(lambda:a11y.poll() is not None,20,"AT-SPI peer process") + out,err=a11y.communicate();(RESULTS/"a11y.json").write_text(out);(RESULTS/"a11y.log").write_text(err) + assert a11y.returncode==0,"Actual application must appear in AT-SPI" + summary["checks"]["atspi_tree_exported"]=True + win._on_capture_action() + pump_until(lambda:not win._capture_pending,10,"photo completion") + photos=list(Path(xdg.photos_dir()).glob("*.png"));assert photos,"Photo action did not save a file" + with Image.open(photos[-1]) as image: + assert image.size==(642,360) + assert np.asarray(image).std()>20 + summary["checks"]["photo_saved_and_decoded"]=True + from core.backends.ip_backend import IPBackend + with ThreadPoolExecutor() as executor: + snapshot = RESULTS / "backend-photo.jpg" + future = executor.submit(IPBackend().capture_photo, win._active_camera, str(snapshot)) + pump_until(future.done, 20, "network snapshot EOS") + assert future.result() + with Image.open(snapshot) as image: + assert image.size == (642, 360) + summary["checks"]["network_snapshot_eos"] = True + win._on_record_toggle() + pump_until(lambda:win._video_recorder.state=="recording",20,"verified recording encoder") + pump(2.5);win._on_record_toggle() + pump_until(lambda:win._video_recorder._done.is_set(),20,"EOS finalization") + pump(.2) + assert win._video_recorder.state=="idle",win._video_recorder.state + path=win._video_recorder.output_path + probe=subprocess.check_output(["ffprobe","-v","error","-show_streams","-show_format","-of","json",path],timeout=10,text=True) + report=json.loads(probe);assert any(s["codec_type"]=="video" for s in report["streams"]) + assert float(report["format"]["duration"])>1 + subprocess.run(["ffmpeg","-v","error","-i",path,"-f","null","-"],check=True,timeout=20) + (RESULTS/"recording-probe.json").write_text(probe) + summary["checks"]["recording_finalized_and_decoded"]=True + win._sidebar_ctrl._sidebar_tab_btns[3].set_active(True) + assert win._sidebar_ctrl.stack.get_visible_child_name() == "videos" + win._split_view.set_show_sidebar(True) + gallery = win._video_gallery + gallery.refresh() + pump_until(lambda: bool(gallery._entries) and gallery._scope["active"] == 0, + 20, "video gallery metadata") + def descendants(widget): + yield widget + child = widget.get_first_child() + while child: + yield from descendants(child) + child = child.get_next_sibling() + assert any(isinstance(widget, Gtk.Label) and widget.get_label().startswith("0:") + for widget in descendants(gallery)) + assert any(isinstance(widget, Gtk.Image) and widget.get_icon_name() == "media-playback-start-symbolic" + for widget in descendants(gallery)) + def sidebar_fully_visible(): + valid, bounds = win._split_view.get_sidebar().compute_bounds(win) + return valid and bounds.get_x() >= 0 and bounds.get_x() + bounds.get_width() <= win.get_width() + pump_until(sidebar_fully_visible, 5, "completed sidebar animation") + pump(.1) + capture("03-video-gallery.png") + gallery._set_view("list") + pump_until(lambda: gallery._scope["active"] == 0, 20, "list duration") + row = gallery._list.get_first_child() + row.grab_focus() + focused = win.get_focus() + subprocess.run(["xdotool", "key", "Tab"], check=True, timeout=5) + pump(.2) + assert win.get_focus() is not None and win.get_focus() != focused + summary["checks"]["gallery_duration_play_icon_and_keyboard"] = True + # An actual invalid audio source must fail, not remain visually recording. + recorder=win._video_recorder + recorder.start(None,audio_sources=["bigcam-does-not-exist"],active_audio_sources=["bigcam-does-not-exist"]) + recorder.write_frame(np.full((48,66,3),100,dtype=np.uint8)) + pump_until(lambda:recorder._done.is_set(),20,"error propagation") + assert recorder.state=="error" + summary["checks"]["recording_error_clears_state"]=True + capture("03-recording-result.png") + win._cleanup_and_close() + pump_until(lambda:not app.get_windows(),40,"graceful close") + summary["checks"]["graceful_close"]=True + summary["versions"]={"python":sys.version,"gtk":[Gtk.get_major_version(),Gtk.get_minor_version()], + "adwaita":[Adw.get_major_version(),Adw.get_minor_version()],"gst":Gst.version_string()} + summary["success"]=True +except Exception: + summary["http_requests"] = getattr(server, "requests", 0) + if win and win._stream_engine._pipeline: + summary["pipeline_state"] = str(win._stream_engine._pipeline.get_state(0)) + summary["success"]=False;summary["exception"]=traceback.format_exc();traceback.print_exc() + try:capture("failure.png") + except Exception:pass +finally: + if win: + try:win._video_recorder.stop();win._stream_engine.stop();win._camera_manager.close() + except Exception:traceback.print_exc() + stop.set();server.shutdown();server.server_close() + (RESULTS/"summary.json").write_text(json.dumps(summary,indent=2)) + print(json.dumps(summary,indent=2),flush=True) +# Executor threads must not conceal a failed test indefinitely. +os._exit(0 if summary.get("success") else 1) diff --git a/tests/integration/session.sh b/tests/integration/session.sh new file mode 100644 index 0000000..402dea2 --- /dev/null +++ b/tests/integration/session.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# Private X11/D-Bus/PulseAudio test session. Never expose host devices or sockets. +set -Eeuo pipefail +[[ $EUID != 0 ]] || { + echo "Run graphical tests as a normal user" >&2 + exit 1 +} +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd -P) +export BIGCAM_TEST_RESULTS=${BIGCAM_TEST_RESULTS:?Set an absolute output directory} +mkdir -p "$BIGCAM_TEST_RESULTS" +export XDG_RUNTIME_DIR +XDG_RUNTIME_DIR=$(mktemp -d /tmp/bigcam-tests.XXXXXXXX) +chmod 700 "$XDG_RUNTIME_DIR" +export HOME="$XDG_RUNTIME_DIR/home" XDG_CONFIG_HOME="$XDG_RUNTIME_DIR/config" XDG_CACHE_HOME="$XDG_RUNTIME_DIR/cache" XDG_STATE_HOME="$XDG_RUNTIME_DIR/state" XDG_DATA_HOME="$XDG_RUNTIME_DIR/data" +mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_STATE_HOME" "$XDG_DATA_HOME" +export GTK_A11Y=atspi NO_AT_BRIDGE=0 GDK_BACKEND=x11 GSK_RENDERER=cairo LIBGL_ALWAYS_SOFTWARE=1 +export LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 PYTHONNOUSERSITE=1 +export PULSE_SERVER="unix:$XDG_RUNTIME_DIR/pulse-native" +cleanup() { + cp -a "$XDG_STATE_HOME/." "$BIGCAM_TEST_RESULTS/state/" 2>/dev/null || true + rm -rf -- "$XDG_RUNTIME_DIR" +} +trap cleanup EXIT +xvfb-run -a -s '-screen 0 1280x900x24 -nolisten tcp' dbus-run-session -- bash -s -- "$ROOT" <<'SESSION' + set -euo pipefail + openbox > "$BIGCAM_TEST_RESULTS/openbox.log" 2>&1 & wm=$! + pulseaudio --daemonize=no --exit-idle-time=-1 --disable-shm=yes -n --load="module-native-protocol-unix socket=$XDG_RUNTIME_DIR/pulse-native auth-anonymous=1" --load="module-null-sink sink_name=bigcam_test" > "$BIGCAM_TEST_RESULTS/audio.log" 2>&1 & pa=$! + trap 'kill "$wm" "$pa" 2>/dev/null || true; wait "$wm" "$pa" 2>/dev/null || true' EXIT + for _ in {1..40}; do [[ -S $XDG_RUNTIME_DIR/pulse-native ]] && break; sleep .1; done + /usr/bin/python3 -m pytest -q -p no:cacheprovider "$1/tests/integration/test_recording.py" + /usr/bin/python3 "$1/tests/integration/run_graphical.py" +SESSION diff --git a/tests/integration/test_recording.py b/tests/integration/test_recording.py new file mode 100644 index 0000000..8c02c13 --- /dev/null +++ b/tests/integration/test_recording.py @@ -0,0 +1,231 @@ +"""Real encoders and private PulseAudio streams; no physical input devices.""" + +import errno +import json +import os +import struct +import subprocess +import sys +import time +from itertools import pairwise +from pathlib import Path +from unittest.mock import patch + +import gi +import numpy as np +import pytest + +gi.require_version("Gst", "1.0") +from gi.repository import GLib, Gst + +sys.path.insert( + 0, str(Path(__file__).resolve().parents[2] / "usr/share/biglinux/bigcam") +) +from core.audio_monitor import AudioMonitor +from core.phone_camera import PhoneCameraServer +from core.video_recorder import VideoRecorder +from utils import xdg + +Gst.init(None) + + +@pytest.fixture +def recorder(tmp_path, monkeypatch): + monkeypatch.setattr(xdg, "videos_dir", lambda: str(tmp_path)) + instance = VideoRecorder() + yield instance + instance.stop() + assert instance.wait_finalize(20) + + +def feed(recorder, seconds=2, audio=None): + image = np.full((48, 66, 3), 120, dtype=np.uint8) + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + recorder.write_frame(image) + if audio: + audio() + time.sleep(0.02) + + +def finish(recorder): + recorder.stop() + assert recorder.wait_finalize(20) + assert recorder.state == "idle" + return recorder.output_path + + +def samples(path): + raw = subprocess.check_output( + [ + "ffmpeg", + "-nostdin", + "-v", + "error", + "-i", + path, + "-vn", + "-ac", + "1", + "-ar", + "16000", + "-f", + "f32le", + "-", + ], + timeout=15, + ) + data = np.frombuffer(raw, dtype=" 16000 + return data[-24000:-8000] + + +def amplitude(data, hz): + return abs(np.fft.rfft(data)[hz]) + + +def test_browser_pcm_reaches_recording_without_playback(recorder): + phone = PhoneCameraServer() + phone._owner = "test" + # Keep the playback worker inactive. Recording must not depend on speakers. + phone._audio_thread = type( + "InactivePlayback", (), {"is_alive": lambda self: True} + )() + phone.set_audio_callback(lambda pcm: recorder.write_audio("phone_browser", pcm)) + recorder.start( + None, + audio_sources=["phone_browser"], + active_audio_sources=["phone_browser"], + external_audio={"phone_browser": None}, + ) + count = 0 + + def packet(): + nonlocal count + t = (np.arange(320) + 320 * count) / 16000 + pcm = (np.sin(2 * np.pi * 660 * t) * 12000).astype("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 - - - - -
- -
-

BigCam

- Phone as Webcam -
-
- -
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 @@ + + + +BigCam

BigCam

+

+ +
+ + + + + + +

+ +
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 + +