From f0af00b2d4d5cd30dd14b440d58338ba091f2491 Mon Sep 17 00:00:00 2001 From: abue-ammar Date: Thu, 24 Sep 2026 01:33:22 +0600 Subject: [PATCH] Show the camera again when the join preview reopens CameraPreviewController kept one AVCaptureSession for the whole launch. Cancel freed the panel's preview layer and stopped the session; the next join restarted it under a new preview layer, which stayed black. The preview session has no output of its own, and a reused session like that feeds a new preview layer no frames after a restart. Open Camera only escaped because its photo output keeps the reused session feeding. stop() now drops the session, so every open builds its own. configure() starts on the camera last switched to, unless it has been unplugged, so Open Camera still reopens on the camera the user picked. --- Tinycast/Features/Camera/Service/CameraSession.swift | 11 ++++++++--- docs/features/camera.md | 7 +++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Tinycast/Features/Camera/Service/CameraSession.swift b/Tinycast/Features/Camera/Service/CameraSession.swift index b720f6e92..5f6fd3612 100644 --- a/Tinycast/Features/Camera/Service/CameraSession.swift +++ b/Tinycast/Features/Camera/Service/CameraSession.swift @@ -52,7 +52,11 @@ final class CameraSession { } func stop() { - guard let capture, capture.isRunning else { return } + guard let capture else { return } + // A reused session without an output of its own feeds a new preview layer no frames. + self.capture = nil + photoOutput = nil + guard capture.isRunning else { return } // The camera light must go out with the panel, so this is never left to deallocation. let box = CaptureBox(session: capture) Task.detached { box.session.stopRunning() } @@ -98,9 +102,10 @@ final class CameraSession { }.value } + /// Reopens on the camera last switched to, unless it has been unplugged since. private func configure() -> AVCaptureSession? { - guard let device = AVCaptureDevice.default(for: .video), - let input = try? AVCaptureDeviceInput(device: device) + let preferred = device?.isConnected == true ? device : AVCaptureDevice.default(for: .video) + guard let device = preferred, let input = try? AVCaptureDeviceInput(device: device) else { return nil } let capture = AVCaptureSession() capture.sessionPreset = purpose == .capture ? .photo : .medium diff --git a/docs/features/camera.md b/docs/features/camera.md index 9dc6d815c..025dc1e23 100644 --- a/docs/features/camera.md +++ b/docs/features/camera.md @@ -15,7 +15,9 @@ controller and footer are all that stay in [calendar.md](calendar.md). and blocks on `startRunning` first, then hands a settled `Feed` up — so the first frame is live video rather than a stage swapped out from under the user, and the TCC prompt never takes key from a panel already up. `stop()` runs from the fade-out's completion, so the camera light never - outlives the panel but is never torn down under a visible one either. + outlives the panel but is never torn down under a visible one either. It also drops the + `AVCaptureSession`, so every open builds its own: a reused session with no output of its own — the + preview's — restarts to a black stage. - **Escape, click-away and the shot all end the same way.** Every route goes through `CameraCoordinator.close()`, which drops the panel and stops the session; `windowDidResignKey` is what covers clicking away. Taking a photo closes too — the command is done, so the camera goes out @@ -51,7 +53,8 @@ coordinator rather than in `AppSettings`: it is remembered for the launch, and a that grants nothing is not worth a settings key or a line in a backup. Switching cameras swaps the input inside one `beginConfiguration`/`commitConfiguration` while the -session keeps running, so the stage never blanks. `Switch Camera` only appears when +session keeps running, so the stage never blanks. The next open starts on the camera switched to, +unless it has been unplugged since. `Switch Camera` only appears when `hasMultipleDevices` says the discovery session found more than one. ## Where it is reachable from