Skip to content

Commit b0500f4

Browse files
authored
Merge pull request #32 from ruscher/main
v4.4.1: phone camera notifications, audio reliability, crash fixes
2 parents a6e4bfc + 88a81d6 commit b0500f4

9 files changed

Lines changed: 292 additions & 128 deletions

File tree

README.md

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
<img src="usr/share/biglinux/bigcam/icons/bigcam.svg" alt="BigCam" width="128" height="128">
33
</p>
44

5-
<h1 align="center">BigCam 4.4.0</h1>
5+
<h1 align="center">BigCam 4.4.1</h1>
66

77
<p align="center">
88
<b>The universal webcam control center for Linux — use any camera, including your smartphone, as a professional webcam. No expensive apps needed.</b>
@@ -21,7 +21,7 @@
2121
</p>
2222

2323
<p align="center">
24-
<img src="https://img.shields.io/badge/Version-4.4.0-brightgreen.svg" alt="Version 4.4.0">
24+
<img src="https://img.shields.io/badge/Version-4.4.1-brightgreen.svg" alt="Version 4.4.1">
2525
<img src="https://img.shields.io/badge/License-GPLv3-blue.svg" alt="License: GPL v3">
2626
<img src="https://img.shields.io/badge/Platform-Linux-green.svg" alt="Platform: Linux">
2727
<img src="https://img.shields.io/badge/GTK-4.0-blue.svg" alt="GTK 4.0">
@@ -72,7 +72,7 @@
7272
- **Smile Capture removed**: Removed mediapipe-dependent smile detection feature entirely (code, README, translations).
7373
- **i18n verified**: All UI strings confirmed English and translation-ready across 29 languages.
7474

75-
**Version 4.4.0** (current) is the **reliability and gallery UX overhaul**:
75+
**Version 4.4.1** (current) is the **phone camera & audio reliability update**:
7676

7777
- **Redesigned Photo & Video galleries**: Grid/List view toggle, file metadata display (size, date, duration), selection mode with "Select All" and bulk delete with confirmation dialog, individual item delete with trash icon overlay. List view uses `AdwActionRow` with thumbnail prefix and formatted metadata subtitle.
7878
- **AirPlay stability fix**: When UxPlay dies unexpectedly (signal loss, crash), the disconnect handler now performs full cleanup — releases v4l2loopback device, resets UI, and emits the disconnect signal to the window. Previously, only the status label changed, leaving the stream engine locked and causing cascading failures on reconnect.
@@ -87,9 +87,31 @@ We are grateful to Rafael and Barnabé for starting this journey.
8787

8888
---
8989

90-
## What's New in 4.4.0
90+
## What's New in 4.4.1
9191

92-
### Photo & Video Galleries
92+
### Phone Camera Notifications
93+
94+
- **Toast notification on connect**: AirPlay, scrcpy, and browser phone cameras now show a notification toast with a **"Show"** button instead of switching immediately. The current camera preview continues until the user chooses to switch.
95+
96+
### Audio Reliability
97+
98+
- **PipeWire stream-restore override**: BigCam now resets both mute and volume on its sink-inputs after pipeline start, overriding stale states saved by PipeWire's `module-stream-restore`.
99+
- **External source mute isolation**: Unmuting internal pipelines no longer inadvertently unmutes external sources (e.g. AirPlay audio) that the user intentionally deactivated via checkbox.
100+
- **Checkbox rebuild guard**: Audio source UI rebuilds (triggered by device changes) no longer fire spurious toggle signals.
101+
102+
### Crash Fixes
103+
104+
- **SIGSEGV on v4l2loopback**: Phone cameras (scrcpy/AirPlay) now use GStreamer instead of OpenCV direct capture for v4l2loopback devices, eliminating mmap-related SIGBUS/SIGSEGV crashes.
105+
- **SIGBUS on dialog close**: Phone camera dialog handlers now guard all widget access with a `_closed` flag, preventing access to destroyed GTK widgets after dialog dismissal.
106+
107+
### Hotplug & Camera Persistence
108+
109+
- **Phone cameras preserved across hotplug**: scrcpy and AirPlay cameras (using V4L2 backend) are no longer dropped from the camera list during hotplug detection scans.
110+
- **Resource cleanup on phone connect**: Background virtual cameras and hotplug monitoring are paused when a phone connects, freeing CPU and USB bandwidth.
111+
112+
### Previous (4.4.0)
113+
114+
#### Photo & Video Galleries
93115

94116
- **Grid/List toggle**: Switch between thumbnail grid and detailed list view with a single click. View preference persists per-tab.
95117
- **List view metadata**: Each item shows a small thumbnail prefix, filename as title, and a formatted subtitle with file size, creation date, and duration (videos only).

default.nix

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ let
3131
in
3232
stdenv.mkDerivation {
3333
pname = "bigcam";
34-
version = "4.4.0";
34+
version = "4.4.1";
3535

3636
src = ./.;
3737

usr/share/biglinux/bigcam/constants.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
APP_ID = "br.com.biglinux.bigcam"
77
APP_NAME = "BigCam"
8-
APP_VERSION = "4.4.0"
8+
APP_VERSION = "4.4.1"
99
APP_ICON = "bigcam"
1010
APP_WEBSITE = "https://github.com/biglinux/bigcam"
1111
APP_ISSUE_URL = "https://github.com/biglinux/bigcam/issues"

usr/share/biglinux/bigcam/core/audio_monitor.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,12 +520,66 @@ def _start_source(self, source: str) -> None:
520520
if vol_elem:
521521
self._volume_elements[source] = vol_elem
522522

523+
# Override PipeWire's stream-restore mute state
524+
if not self._muted:
525+
GLib.timeout_add(300, self._ensure_sink_inputs_unmuted)
526+
523527
def _stop_source(self, source: str) -> None:
524528
pipeline = self._pipelines.pop(source, None)
525529
self._volume_elements.pop(source, None)
526530
if pipeline:
527531
pipeline.set_state(Gst.State.NULL)
528532

533+
def _ensure_sink_inputs_unmuted(self) -> bool:
534+
"""Override PipeWire's module-stream-restore mute for BigCam sinks."""
535+
try:
536+
result = subprocess.run(
537+
["pactl", "list", "sink-inputs"],
538+
capture_output=True, text=True, timeout=3,
539+
)
540+
if result.returncode != 0:
541+
return GLib.SOURCE_REMOVE
542+
cur_idx: int | None = None
543+
is_bigcam = False
544+
for line in result.stdout.splitlines():
545+
stripped = line.strip()
546+
if stripped.startswith("Sink Input #"):
547+
if is_bigcam and cur_idx is not None:
548+
subprocess.run(
549+
["pactl", "set-sink-input-mute", str(cur_idx), "0"],
550+
capture_output=True, timeout=3,
551+
)
552+
subprocess.run(
553+
["pactl", "set-sink-input-volume", str(cur_idx), "100%"],
554+
capture_output=True, timeout=3,
555+
)
556+
try:
557+
cur_idx = int(stripped.split("#", 1)[1])
558+
except ValueError:
559+
cur_idx = None
560+
is_bigcam = False
561+
elif 'application.name = "BigCam"' in stripped:
562+
is_bigcam = True
563+
# Handle last entry
564+
if is_bigcam and cur_idx is not None:
565+
subprocess.run(
566+
["pactl", "set-sink-input-mute", str(cur_idx), "0"],
567+
capture_output=True, timeout=3,
568+
)
569+
subprocess.run(
570+
["pactl", "set-sink-input-volume", str(cur_idx), "100%"],
571+
capture_output=True, timeout=3,
572+
)
573+
except (FileNotFoundError, subprocess.TimeoutExpired):
574+
pass
575+
576+
# Re-mute external sources that the user intentionally deactivated
577+
for name, info in self._external.items():
578+
if not info.get("active", True):
579+
self._pactl_mute_external(name, True)
580+
581+
return GLib.SOURCE_REMOVE
582+
529583
def _on_bus_eos(
530584
self, _bus: Gst.Bus, _msg: Gst.Message, source: str
531585
) -> None:

usr/share/biglinux/bigcam/core/camera_manager.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,10 @@ def _worker() -> None:
129129
def _on_detection_done(self, cameras: list[CameraInfo]) -> bool:
130130
# Preserve manually-added cameras (IP, phone) across hotplug scans
131131
manual_backends = {BackendType.IP, BackendType.PHONE}
132-
manual_cameras = [c for c in self._cameras if c.backend in manual_backends]
132+
manual_cameras = [
133+
c for c in self._cameras
134+
if c.backend in manual_backends or c.id.startswith("phone:")
135+
]
133136
seen_ids = {c.id for c in cameras}
134137
for mc in manual_cameras:
135138
if mc.id not in seen_ids:

usr/share/biglinux/bigcam/core/stream_engine.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -731,7 +731,9 @@ def _build_paintable_pipeline(self, gst_source: str, target_fps: int = 0) -> boo
731731
(bypasses GStreamer's internal queue/timing, like guvcview).
732732
"""
733733
# Radical anti-flicker: use appsink (like guvcview) instead of paintable sink
734-
if self._prefer_v4l2 and "v4l2src" in gst_source:
734+
# Skip for phone cameras — v4l2loopback + OpenCV mmap causes SIGBUS/SIGSEGV
735+
is_phone = self._current_camera and self._current_camera.id.startswith("phone:")
736+
if self._prefer_v4l2 and "v4l2src" in gst_source and not is_phone:
735737
if self._build_direct_pipeline(gst_source, target_fps):
736738
return True
737739

@@ -865,14 +867,18 @@ def _build_direct_pipeline(self, gst_source: str, target_fps: int = 0) -> bool:
865867
return False
866868

867869
# Configure format to match what BigCam normally uses
868-
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter.fourcc('M', 'J', 'P', 'G'))
870+
# For phone cameras (v4l2loopback), skip MJPG — the writer sets the
871+
# raw format (YUY2/NV12) and forcing MJPG can cause SIGBUS/SIGSEGV.
872+
is_phone = camera.id.startswith("phone:")
873+
if not is_phone:
874+
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter.fourcc('M', 'J', 'P', 'G'))
869875
fmt = self._current_fmt
870876
if fmt:
871877
cap.set(cv2.CAP_PROP_FRAME_WIDTH, fmt.width)
872878
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, fmt.height)
873879
if fmt.fps:
874880
cap.set(cv2.CAP_PROP_FPS, max(fmt.fps))
875-
else:
881+
elif not is_phone:
876882
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
877883
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
878884
cap.set(cv2.CAP_PROP_FPS, 30)

usr/share/biglinux/bigcam/ui/phone_camera_dialog.py

Lines changed: 57 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ def __init__(
7171
self._active_mode: str | None = None
7272
# Track which tab started scrcpy (USB or Wi-Fi)
7373
self._scrcpy_tab: str | None = None
74+
# Guard against widget access after dialog destroyed
75+
self._closed: bool = False
7476

7577
self._build_ui()
7678
self._connect_backend_signals()
@@ -1627,38 +1629,42 @@ def _on_scrcpy_stop(self, _btn: Gtk.Button) -> None:
16271629
def _on_scrcpy_connected(
16281630
self, _scrcpy: ScrcpyCamera, w: int, h: int
16291631
) -> None:
1630-
self._set_dot_color(0.2, 0.78, 0.35)
1631-
if self._scrcpy_tab == _TAB_USB:
1632-
self._set_status(_("Connected via USB"))
1633-
else:
1634-
self._set_status(_("Connected via Wi-Fi"))
1635-
self._set_resolution(w, h)
1632+
if not self._closed:
1633+
self._set_dot_color(0.2, 0.78, 0.35)
1634+
if self._scrcpy_tab == _TAB_USB:
1635+
self._set_status(_("Connected via USB"))
1636+
else:
1637+
self._set_status(_("Connected via Wi-Fi"))
1638+
self._set_resolution(w, h)
16361639
self.emit("scrcpy-connected", w, h)
16371640

16381641
def _on_scrcpy_disconnected(self, _scrcpy: ScrcpyCamera) -> None:
1639-
self._set_dot_color(0.85, 0.2, 0.2)
1640-
self._set_status(_("Disconnected"))
1641-
self._set_resolution(0, 0)
1642-
# Reset only the tab that started scrcpy
1643-
if self._scrcpy_tab == _TAB_USB:
1644-
self._usb_start_btn.set_visible(True)
1645-
self._usb_stop_btn.set_visible(False)
1646-
elif self._scrcpy_tab == _TAB_WIFI_ADV:
1647-
self._scrcpy_start_btn.set_visible(True)
1648-
self._scrcpy_stop_btn.set_visible(False)
1649-
else:
1650-
# Unknown source — reset both
1651-
self._scrcpy_start_btn.set_visible(True)
1652-
self._scrcpy_stop_btn.set_visible(False)
1653-
self._usb_start_btn.set_visible(True)
1654-
self._usb_stop_btn.set_visible(False)
1655-
self._scrcpy_tab = None
1656-
self._set_mode_lock(None)
1642+
if not self._closed:
1643+
self._set_dot_color(0.85, 0.2, 0.2)
1644+
self._set_status(_("Disconnected"))
1645+
self._set_resolution(0, 0)
1646+
# Reset only the tab that started scrcpy
1647+
if self._scrcpy_tab == _TAB_USB:
1648+
self._usb_start_btn.set_visible(True)
1649+
self._usb_stop_btn.set_visible(False)
1650+
elif self._scrcpy_tab == _TAB_WIFI_ADV:
1651+
self._scrcpy_start_btn.set_visible(True)
1652+
self._scrcpy_stop_btn.set_visible(False)
1653+
else:
1654+
# Unknown source — reset both
1655+
self._scrcpy_start_btn.set_visible(True)
1656+
self._scrcpy_stop_btn.set_visible(False)
1657+
self._usb_start_btn.set_visible(True)
1658+
self._usb_stop_btn.set_visible(False)
1659+
self._scrcpy_tab = None
1660+
self._set_mode_lock(None)
16571661
self.emit("scrcpy-disconnected")
16581662

16591663
def _on_scrcpy_status_changed(
16601664
self, _scrcpy: ScrcpyCamera, status: str
16611665
) -> None:
1666+
if self._closed:
1667+
return
16621668
if status == "starting":
16631669
self._set_dot_color(1.0, 0.76, 0.03)
16641670
self._set_status(_("Starting…"))
@@ -1752,9 +1758,10 @@ def _on_airplay_stop(self, _btn: Gtk.Button) -> None:
17521758
def _on_airplay_connected(
17531759
self, _receiver: AirPlayReceiver, w: int, h: int
17541760
) -> None:
1755-
self._set_dot_color(0.2, 0.78, 0.35)
1756-
self._set_status(_("AirPlay connected"))
1757-
self._set_resolution(w, h)
1761+
if not self._closed:
1762+
self._set_dot_color(0.2, 0.78, 0.35)
1763+
self._set_status(_("AirPlay connected"))
1764+
self._set_resolution(w, h)
17581765
self.emit("airplay-connected", w, h)
17591766

17601767
def _on_airplay_disconnected(
@@ -1763,39 +1770,48 @@ def _on_airplay_disconnected(
17631770
if not self._airplay.running:
17641771
# UxPlay process died — full cleanup
17651772
VirtualCamera.release_device("phone:airplay")
1766-
self._airplay_start_btn.set_visible(True)
1767-
self._airplay_stop_btn.set_visible(False)
1768-
self._set_dot_color(0.6, 0.6, 0.6)
1769-
self._set_status(_("AirPlay stopped unexpectedly"))
1770-
self._set_resolution(0, 0)
1771-
self._set_mode_lock(None)
1773+
if not self._closed:
1774+
self._airplay_start_btn.set_visible(True)
1775+
self._airplay_stop_btn.set_visible(False)
1776+
self._set_dot_color(0.6, 0.6, 0.6)
1777+
self._set_status(_("AirPlay stopped unexpectedly"))
1778+
self._set_resolution(0, 0)
1779+
self._set_mode_lock(None)
17721780
self.emit("airplay-disconnected")
17731781
else:
17741782
# Client disconnected but UxPlay still running (can reconnect)
1775-
self._set_dot_color(1.0, 0.76, 0.03)
1776-
self._set_status(_("Waiting for AirPlay connection…"))
1777-
self._set_resolution(0, 0)
1783+
if not self._closed:
1784+
self._set_dot_color(1.0, 0.76, 0.03)
1785+
self._set_status(_("Waiting for AirPlay connection…"))
1786+
self._set_resolution(0, 0)
1787+
# Tell window to restore previous camera while waiting
1788+
self.emit("airplay-disconnected")
17781789

17791790
def _on_airplay_status_changed(
17801791
self, _receiver: AirPlayReceiver, status: str
17811792
) -> None:
1782-
self._set_status(status)
1793+
if not self._closed:
1794+
self._set_status(status)
17831795

17841796
# ══════════════════════════════════════════════════════════════════
17851797
# DIALOG LIFECYCLE
17861798
# ══════════════════════════════════════════════════════════════════
17871799

17881800
def _on_dialog_closed(self, _dialog: Adw.Dialog) -> None:
1801+
self._closed = True
17891802
if self._usb_poll_id:
17901803
GLib.source_remove(self._usb_poll_id)
17911804
self._usb_poll_id = 0
17921805
for sid in self._server_sig_ids:
17931806
self._server.disconnect(sid)
17941807
self._server_sig_ids.clear()
1795-
for sid in self._scrcpy_sig_ids:
1796-
self._scrcpy.disconnect(sid)
1797-
self._scrcpy_sig_ids.clear()
1798-
if self._airplay:
1808+
# Only disconnect scrcpy signals if scrcpy is NOT running
1809+
if not self._scrcpy.running:
1810+
for sid in self._scrcpy_sig_ids:
1811+
self._scrcpy.disconnect(sid)
1812+
self._scrcpy_sig_ids.clear()
1813+
# Only disconnect airplay signals if airplay is NOT running
1814+
if self._airplay and not self._airplay.running:
17991815
for sid in self._airplay_sig_ids:
18001816
self._airplay.disconnect(sid)
18011817
self._airplay_sig_ids.clear()

usr/share/biglinux/bigcam/ui/preview_area.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from __future__ import annotations
44

5-
65
import gi
76

87
gi.require_version("Gtk", "4.0")
@@ -334,6 +333,8 @@ def set_audio_monitor(self, monitor: AudioMonitor | None) -> None:
334333
self._audio_box.set_visible(False)
335334

336335
def _on_sources_changed(self, mon: AudioMonitor) -> None:
336+
# Block toggle handler during UI rebuild
337+
self._audio_rebuilding = True
337338
# Clear old checkboxes
338339
while True:
339340
child = self._audio_checks_box.get_first_child()
@@ -354,6 +355,7 @@ def _on_sources_changed(self, mon: AudioMonitor) -> None:
354355
sources = mon.sources
355356
if not sources:
356357
self._audio_box.set_visible(False)
358+
self._audio_rebuilding = False
357359
return
358360

359361
for idx, (src_name, label) in enumerate(sources, start=1):
@@ -424,6 +426,7 @@ def _on_sources_changed(self, mon: AudioMonitor) -> None:
424426
self._audio_box.add_controller(self._audio_hover_ctrl)
425427

426428
self._audio_box.set_visible(True)
429+
self._audio_rebuilding = False
427430

428431
def _on_audio_hover_enter(self, *_args) -> None:
429432
"""Show volume sliders when mouse enters the audio overlay."""
@@ -437,7 +440,7 @@ def _on_audio_hover_leave(self, *_args) -> None:
437440
def _on_audio_check_toggled(
438441
self, check: Gtk.CheckButton, source_name: str
439442
) -> None:
440-
if not self._audio_monitor:
443+
if not self._audio_monitor or getattr(self, '_audio_rebuilding', False):
441444
return
442445
is_active = self._audio_monitor.is_active(source_name)
443446
want_active = check.get_active()

0 commit comments

Comments
 (0)