From ca15f66080cde9d933ba45caca51e03b7a5dfce2 Mon Sep 17 00:00:00 2001 From: Taksh Date: Mon, 17 Aug 2026 10:48:29 +0530 Subject: [PATCH] fix: release the playback slot when a note is paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pause() left activeController set, so the slot meant "a row was played at some point" rather than "audio is audible". Any reader of hasActivePlayback — the swipe-to-leave policy above all — kept seeing playback after the user paused, until the row disappeared or something called stop(). A paused note has nothing to yield to the next playback, so it releases the slot. hasActivePlayback becomes stored and published, so a view can react to it rather than sample it once. --- .../voice/VoiceNotePlaybackController.swift | 17 +++++++-- .../PrivateChatSwipeToLeavePolicyTests.swift | 37 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/bitchat/Features/voice/VoiceNotePlaybackController.swift b/bitchat/Features/voice/VoiceNotePlaybackController.swift index 0019719f..f3c14ec7 100644 --- a/bitchat/Features/voice/VoiceNotePlaybackController.swift +++ b/bitchat/Features/voice/VoiceNotePlaybackController.swift @@ -113,6 +113,11 @@ final class VoiceNotePlaybackController: NSObject, ObservableObject, AVAudioPlay updateProgress() isPlaying = false releaseSession() + // The slot means "audio is audible", not "a row is selected": a paused + // note has nothing to yield to the next playback, and readers of + // `hasActivePlayback` would otherwise see playback that stopped being + // heard when the user paused it. + exclusivity.deactivate(self) } func stop() { @@ -296,7 +301,7 @@ extension VoiceNotePlaybackController: ExclusivePlayback { } /// Ensures only one voice playback (note or live burst) runs at a time. -final class VoiceNotePlaybackCoordinator { +final class VoiceNotePlaybackCoordinator: ObservableObject { static let shared = VoiceNotePlaybackCoordinator() struct Reservation: Equatable { @@ -339,6 +344,7 @@ final class VoiceNotePlaybackCoordinator { } activeController?.pauseForExclusivity() activeController = controller + hasActivePlayback = true return true } @@ -347,9 +353,11 @@ final class VoiceNotePlaybackCoordinator { /// Read by the private-chat swipe-to-leave gesture, which stands down while /// a voice note is audible so a waveform seek is not starved by the /// high-priority ancestor drag. See `PrivateChatSwipeToLeavePolicy`. - var hasActivePlayback: Bool { - activeController != nil - } + /// + /// Published rather than computed so a view can drop the gesture for the + /// duration: a gesture that is armed at all starves its descendants, so + /// reading this only once the drag ends comes too late for the seek. + @Published private(set) var hasActivePlayback: Bool = false func isCurrent(_ reservation: Reservation, for controller: any ExclusivePlayback) -> Bool { latestReservation == reservation && latestReservedController === controller @@ -358,6 +366,7 @@ final class VoiceNotePlaybackCoordinator { func deactivate(_ controller: any ExclusivePlayback) { if activeController === controller { activeController = nil + hasActivePlayback = false } if latestReservedController === controller { latestReservedController = nil diff --git a/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift b/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift index ece0d61e..f56423e8 100644 --- a/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift +++ b/bitchatTests/PrivateChatSwipeToLeavePolicyTests.swift @@ -8,6 +8,7 @@ import Testing import CoreGraphics +import Foundation @testable import bitchat /// A stand-in for the playback slot holder. The coordinator only needs identity @@ -136,6 +137,42 @@ struct VoiceNotePlaybackCoordinatorActivityTests { #expect(coordinator.hasActivePlayback) } + @Test("a controller that pauses releases the slot") + func pauseReleasesSlot() throws { + // The slot means audible playback. A paused note holds nothing, and + // leaving it in place kept the leave gesture suppressed for the rest + // of the row's life. + let coordinator = VoiceNotePlaybackCoordinator() + let url = try Self.makeSilentVoiceNote() + defer { try? FileManager.default.removeItem(at: url) } + let controller = VoiceNotePlaybackController(url: url, exclusivity: coordinator) + + controller.play() + #expect(coordinator.hasActivePlayback) + + controller.pause() + #expect(!coordinator.hasActivePlayback) + } + + /// A one-frame WAV: enough for AVAudioPlayer to prepare and start. + private static func makeSilentVoiceNote() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("swipe-policy-\(UUID().uuidString).wav") + var data = Data() + func append(_ string: String) { data.append(contentsOf: Array(string.utf8)) } + func append(_ value: UInt32) { withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } } + func append(_ value: UInt16) { withUnsafeBytes(of: value.littleEndian) { data.append(contentsOf: $0) } } + let frames: UInt32 = 8_000 // one second at 8 kHz, 16-bit mono + let dataBytes = frames * 2 + append("RIFF"); append(36 + dataBytes); append("WAVE") + append("fmt "); append(UInt32(16)); append(UInt16(1)); append(UInt16(1)) + append(UInt32(8_000)); append(UInt32(16_000)); append(UInt16(2)); append(UInt16(16)) + append("data"); append(dataBytes) + data.append(Data(repeating: 0, count: Int(dataBytes))) + try data.write(to: url) + return url + } + @Test("taking over the slot pauses the previous holder and stays active") func takeoverKeepsPlaybackActive() { let coordinator = VoiceNotePlaybackCoordinator()